diff --git a/.core_files.yaml b/.core_files.yaml
index 27d51a0ced3..2a6db0a2943 100644
--- a/.core_files.yaml
+++ b/.core_files.yaml
@@ -91,6 +91,7 @@ components: &components
- homeassistant/components/input_number/**
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
+ - homeassistant/components/labs/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 839cb00f3de..112b3f27d9c 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -40,7 +40,8 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
- "python.analysis.typeCheckingMode": "basic",
+ // Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
+ "python.analysis.typeCheckingMode": "off",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 03174c0d2b1..51e4526104b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -847,8 +847,8 @@ rules:
## Development Commands
### Code Quality & Linting
-- **Run all linters on all files**: `pre-commit run --all-files`
-- **Run linters on staged files only**: `pre-commit run`
+- **Run all linters on all files**: `prek run --all-files`
+- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
@@ -1024,18 +1024,6 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
)
```
-### Entity Performance Optimization
-```python
-# Use __slots__ for memory efficiency
-class MySensor(SensorEntity):
- __slots__ = ("_attr_native_value", "_attr_available")
-
- @property
- def should_poll(self) -> bool:
- """Disable polling when using coordinator."""
- return False # ✅ Let coordinator handle updates
-```
-
## Testing Patterns
### Testing Best Practices
@@ -1181,4 +1169,4 @@ python -m script.hassfest --integration-path homeassistant/components/my_integra
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
-```
\ No newline at end of file
+```
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 1d513ba59e4..703ffd14f38 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -59,7 +59,6 @@ env:
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
- PRE_COMMIT_CACHE: ~/.cache/pre-commit
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
@@ -83,7 +82,6 @@ jobs:
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
- pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -111,11 +109,6 @@ jobs:
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}-${{
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- - name: Generate partial pre-commit restore key
- id: generate_pre-commit_cache_key
- run: >-
- echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
- hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
@@ -244,8 +237,8 @@ jobs:
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
- pre-commit:
- name: Prepare pre-commit base
+ prek:
+ name: Run prek checks
runs-on: *runs-on-ubuntu
needs: [info]
if: |
@@ -254,147 +247,17 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- - &setup-python-default
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
- id: cache-venv
- uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
- with:
- path: venv
- key: &key-pre-commit-venv >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
- needs.info.outputs.pre-commit_cache_key }}
- - name: Create Python virtual environment
- if: steps.cache-venv.outputs.cache-hit != 'true'
- run: |
- python -m venv venv
- . venv/bin/activate
- python --version
- pip install "$(grep '^uv' < requirements.txt)"
- uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- - name: Restore pre-commit environment from cache
- id: cache-precommit
- uses: *actions-cache
- with:
- path: ${{ env.PRE_COMMIT_CACHE }}
- lookup-only: true
- key: &key-pre-commit-env >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.pre-commit_cache_key }}
- - name: Install pre-commit dependencies
- if: steps.cache-precommit.outputs.cache-hit != 'true'
- run: |
- . venv/bin/activate
- pre-commit install-hooks
-
- lint-ruff-format:
- name: Check ruff-format
- runs-on: *runs-on-ubuntu
- needs: &needs-pre-commit
- - info
- - pre-commit
- steps:
- - *checkout
- - *setup-python-default
- - &cache-restore-pre-commit-venv
- name: Restore base Python virtual environment
- id: cache-venv
- uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
- with:
- path: venv
- fail-on-cache-miss: true
- key: *key-pre-commit-venv
- - &cache-restore-pre-commit-env
- name: Restore pre-commit environment from cache
- id: cache-precommit
- uses: *actions-cache-restore
- with:
- path: ${{ env.PRE_COMMIT_CACHE }}
- fail-on-cache-miss: true
- key: *key-pre-commit-env
- - name: Run ruff-format
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
- env:
- RUFF_OUTPUT_FORMAT: github
-
- lint-ruff:
- name: Check ruff
- runs-on: *runs-on-ubuntu
- needs: *needs-pre-commit
- steps:
- - *checkout
- - *setup-python-default
- - *cache-restore-pre-commit-venv
- - *cache-restore-pre-commit-env
- - name: Run ruff
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
- env:
- RUFF_OUTPUT_FORMAT: github
-
- lint-other:
- name: Check other linters
- runs-on: *runs-on-ubuntu
- needs: *needs-pre-commit
- steps:
- - *checkout
- - *setup-python-default
- - *cache-restore-pre-commit-venv
- - *cache-restore-pre-commit-env
-
- - name: Register yamllint problem matcher
+ - name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- - name: Run yamllint
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
-
- - name: Register check-json problem matcher
- run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- - name: Run check-json
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
-
- - name: Run prettier (fully)
- if: needs.info.outputs.test_full_suite == 'true'
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
-
- - name: Run prettier (partially)
- if: needs.info.outputs.test_full_suite == 'false'
- shell: bash
- run: |
- . venv/bin/activate
- shopt -s globstar
- pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
-
- - name: Register check executables problem matcher
- run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- - name: Run executables check
- run: |
- . venv/bin/activate
- pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
-
- - name: Register codespell problem matcher
- run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- - name: Run codespell
- run: |
- . venv/bin/activate
- pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
+ - name: Run prek
+ uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
+ env:
+ PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
+ RUFF_OUTPUT_FORMAT: github
lint-hadolint:
name: Check ${{ matrix.file }}
@@ -434,7 +297,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
- uses: *actions-setup-python
+ uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -447,7 +310,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
- uses: *actions-cache
+ uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: &key-python-venv >-
@@ -511,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
- uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
+ uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -562,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
- uses: *actions-cache-restore
+ uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -579,7 +442,13 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- *checkout
- - *setup-python-default
+ - &setup-python-default
+ name: Set up Python ${{ env.DEFAULT_PYTHON }}
+ id: python
+ uses: *actions-setup-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ check-latest: true
- &cache-restore-python-default
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
@@ -782,9 +651,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- - lint-other
- - lint-ruff
- - lint-ruff-format
+ - prek
- mypy
steps:
- *cache-restore-apt
@@ -823,9 +690,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- - lint-other
- - lint-ruff
- - lint-ruff-format
+ - prek
- mypy
- prepare-pytest-full
if: |
@@ -949,9 +814,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- - lint-other
- - lint-ruff
- - lint-ruff-format
+ - prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1066,9 +929,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- - lint-other
- - lint-ruff
- - lint-ruff-format
+ - prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1202,9 +1063,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- - lint-other
- - lint-ruff
- - lint-ruff-format
+ - prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1328,6 +1187,8 @@ jobs:
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
+ permissions:
+ id-token: write
# codecov/test-results-action currently doesn't support tokenless uploads
# therefore we can't run it on forks
if: |
@@ -1339,8 +1200,9 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
+ report_type: test_results
fail_ci_if_error: true
verbose: true
- token: ${{ secrets.CODECOV_TOKEN }}
+ use_oidc: true
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 2f3c48be0ba..aa094507e46 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
- uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
category: "/language:python"
diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml
index 055aab92b2b..ee58cd1e9e8 100644
--- a/.github/workflows/detect-duplicate-issues.yml
+++ b/.github/workflows/detect-duplicate-issues.yml
@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
- uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
+ uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o
system-prompt: |
diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml
index a5e373b9ee6..fab2ee99219 100644
--- a/.github/workflows/detect-non-english-issues.yml
+++ b/.github/workflows/detect-non-english-issues.yml
@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
- uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
+ uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o-mini
system-prompt: |
diff --git a/.github/workflows/matchers/check-executables-have-shebangs.json b/.github/workflows/matchers/check-executables-have-shebangs.json
index 667ef795632..1ff6ae0e94c 100644
--- a/.github/workflows/matchers/check-executables-have-shebangs.json
+++ b/.github/workflows/matchers/check-executables-have-shebangs.json
@@ -4,7 +4,7 @@
"owner": "check-executables-have-shebangs",
"pattern": [
{
- "regexp": "^(.+):\\s(.+)$",
+ "regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
"file": 1,
"message": 2
}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0d0b9cd27ba..152719ddf01 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -39,14 +39,14 @@ repos:
- id: prettier
additional_dependencies:
- prettier@3.6.2
- - prettier-plugin-sort-json@4.1.1
+ - prettier-plugin-sort-json@4.2.0
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
# Will require manual work, before submitting changes!
- # pre-commit run --hook-stage manual python-typing-update --all-files
+ # prek run --hook-stage manual python-typing-update --all-files
- id: python-typing-update
stages: [manual]
args:
diff --git a/.strict-typing b/.strict-typing
index 91d91103c91..625f80e38c0 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -407,6 +407,7 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
+homeassistant.components.pooldose.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerwall.*
@@ -454,6 +455,7 @@ homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
+homeassistant.components.saunum.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
homeassistant.components.schlage.*
diff --git a/.vscode/settings.default.jsonc b/.vscode/settings.default.jsonc
index bee6d5ef521..28e65799c70 100644
--- a/.vscode/settings.default.jsonc
+++ b/.vscode/settings.default.jsonc
@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
- // Pyright is too pedantic for Home Assistant
- "python.analysis.typeCheckingMode": "basic",
+ // Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
+ "python.analysis.typeCheckingMode": "off",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 50bb89daf38..f66159370f4 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
- "command": "pre-commit run ruff-check --all-files",
+ "command": "prek run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": []
},
{
- "label": "Pre-commit",
+ "label": "Prek",
"type": "shell",
- "command": "pre-commit run --show-diff-on-failure",
+ "command": "prek run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true
@@ -120,7 +120,7 @@
{
"label": "Generate Requirements",
"type": "shell",
- "command": "./script/gen_requirements_all.py",
+ "command": "${command:python.interpreterPath} -m script.gen_requirements_all",
"group": {
"kind": "build",
"isDefault": true
diff --git a/CODEOWNERS b/CODEOWNERS
index b6459c82ac8..949b8ff0b91 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST
/tests/components/min_max/ @gjohansson-ST
-/homeassistant/components/minecraft_server/ @elmurato
-/tests/components/minecraft_server/ @elmurato
+/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
+/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/moat/ @bdraco
@@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
+/homeassistant/components/namecheapdns/ @tr4nt0r
+/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
@@ -1271,7 +1273,8 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
-/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
+/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
+/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py
index 65d20a4e88c..bdc89e23f57 100644
--- a/homeassistant/components/adguard/entity.py
+++ b/homeassistant/components/adguard/entity.py
@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
def device_info(self) -> DeviceInfo:
"""Return device information about this AdGuard Home instance."""
if self._entry.source == SOURCE_HASSIO:
- config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
+ config_url = "homeassistant://app/a0d7b954_adguard"
elif self.adguard.tls:
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
else:
diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py
index 9cfb819b90d..abd3f5e53b3 100644
--- a/homeassistant/components/airobot/__init__.py
+++ b/homeassistant/components/airobot/__init__.py
@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
+ Platform.SWITCH,
]
diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py
index fba02b6fe1e..6768fa4cf9b 100644
--- a/homeassistant/components/airobot/button.py
+++ b/homeassistant/components/airobot/button.py
@@ -43,6 +43,13 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
+ AirobotButtonEntityDescription(
+ key="recalibrate_co2",
+ translation_key="recalibrate_co2",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
+ ),
)
diff --git a/homeassistant/components/airobot/climate.py b/homeassistant/components/airobot/climate.py
index 36dc90cf82e..9da653509f9 100644
--- a/homeassistant/components/airobot/climate.py
+++ b/homeassistant/components/airobot/climate.py
@@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
_attr_min_temp = SETPOINT_TEMP_MIN
_attr_max_temp = SETPOINT_TEMP_MAX
+ def __init__(self, coordinator) -> None:
+ """Initialize the climate entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = coordinator.data.status.device_id
+
@property
def _status(self) -> ThermostatStatus:
"""Get status from coordinator data."""
diff --git a/homeassistant/components/airobot/entity.py b/homeassistant/components/airobot/entity.py
index 7a72e42364a..98a00d20c4b 100644
--- a/homeassistant/components/airobot/entity.py
+++ b/homeassistant/components/airobot/entity.py
@@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
status = coordinator.data.status
settings = coordinator.data.settings
- self._attr_unique_id = status.device_id
-
connections = set()
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
connections.add((CONNECTION_NETWORK_MAC, mac))
diff --git a/homeassistant/components/airobot/icons.json b/homeassistant/components/airobot/icons.json
index 2ea387512e4..c230efb3707 100644
--- a/homeassistant/components/airobot/icons.json
+++ b/homeassistant/components/airobot/icons.json
@@ -1,9 +1,22 @@
{
"entity": {
+ "button": {
+ "recalibrate_co2": {
+ "default": "mdi:molecule-co2"
+ }
+ },
"number": {
"hysteresis_band": {
"default": "mdi:delta"
}
+ },
+ "switch": {
+ "actuator_exercise_disabled": {
+ "default": "mdi:valve"
+ },
+ "child_lock": {
+ "default": "mdi:lock"
+ }
}
}
}
diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json
index ac9f26b2e4c..ecccf553736 100644
--- a/homeassistant/components/airobot/strings.json
+++ b/homeassistant/components/airobot/strings.json
@@ -59,6 +59,11 @@
}
},
"entity": {
+ "button": {
+ "recalibrate_co2": {
+ "name": "Recalibrate CO2 sensor"
+ }
+ },
"number": {
"hysteresis_band": {
"name": "Hysteresis band"
@@ -80,6 +85,14 @@
"heating_uptime": {
"name": "Heating uptime"
}
+ },
+ "switch": {
+ "actuator_exercise_disabled": {
+ "name": "Actuator exercise disabled"
+ },
+ "child_lock": {
+ "name": "Child lock"
+ }
}
},
"exceptions": {
@@ -100,6 +113,12 @@
},
"set_value_failed": {
"message": "Failed to set value: {error}"
+ },
+ "switch_turn_off_failed": {
+ "message": "Failed to turn off {switch}."
+ },
+ "switch_turn_on_failed": {
+ "message": "Failed to turn on {switch}."
}
}
}
diff --git a/homeassistant/components/airobot/switch.py b/homeassistant/components/airobot/switch.py
new file mode 100644
index 00000000000..3a7c5d8222d
--- /dev/null
+++ b/homeassistant/components/airobot/switch.py
@@ -0,0 +1,118 @@
+"""Switch platform for Airobot thermostat."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any
+
+from pyairobotrest.exceptions import AirobotError
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import AirobotConfigEntry
+from .const import DOMAIN
+from .coordinator import AirobotDataUpdateCoordinator
+from .entity import AirobotEntity
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class AirobotSwitchEntityDescription(SwitchEntityDescription):
+ """Describes Airobot switch entity."""
+
+ is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool]
+ turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
+ turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
+
+
+SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = (
+ AirobotSwitchEntityDescription(
+ key="child_lock",
+ translation_key="child_lock",
+ entity_category=EntityCategory.CONFIG,
+ is_on_fn=lambda coordinator: (
+ coordinator.data.settings.setting_flags.childlock_enabled
+ ),
+ turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True),
+ turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False),
+ ),
+ AirobotSwitchEntityDescription(
+ key="actuator_exercise_disabled",
+ translation_key="actuator_exercise_disabled",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ is_on_fn=lambda coordinator: (
+ coordinator.data.settings.setting_flags.actuator_exercise_disabled
+ ),
+ turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
+ True
+ ),
+ turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
+ False
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AirobotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Airobot switch entities."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ AirobotSwitch(coordinator, description) for description in SWITCH_TYPES
+ )
+
+
+class AirobotSwitch(AirobotEntity, SwitchEntity):
+ """Representation of an Airobot switch."""
+
+ entity_description: AirobotSwitchEntityDescription
+
+ def __init__(
+ self,
+ coordinator: AirobotDataUpdateCoordinator,
+ description: AirobotSwitchEntityDescription,
+ ) -> None:
+ """Initialize the switch."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the switch is on."""
+ return self.entity_description.is_on_fn(self.coordinator)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ try:
+ await self.entity_description.turn_on_fn(self.coordinator)
+ except AirobotError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn_on_failed",
+ translation_placeholders={"switch": self.entity_description.key},
+ ) from err
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ try:
+ await self.entity_description.turn_off_fn(self.coordinator)
+ except AirobotError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn_off_failed",
+ translation_placeholders={"switch": self.entity_description.key},
+ ) from err
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py
index c0d7901981b..7513eec8a75 100644
--- a/homeassistant/components/airzone/entity.py
+++ b/homeassistant/components/airzone/entity.py
@@ -85,6 +85,22 @@ class AirzoneSystemEntity(AirzoneEntity):
value = system[key]
return value
+ async def _async_update_sys_params(self, params: dict[str, Any]) -> None:
+ """Send system parameters to API."""
+ _params = {
+ API_SYSTEM_ID: self.system_id,
+ **params,
+ }
+ _LOGGER.debug("update_sys_params=%s", _params)
+ try:
+ await self.coordinator.airzone.set_sys_parameters(_params)
+ except AirzoneError as error:
+ raise HomeAssistantError(
+ f"Failed to set system {self.entity_id}: {error}"
+ ) from error
+
+ self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
+
class AirzoneHotWaterEntity(AirzoneEntity):
"""Define an Airzone Hot Water entity."""
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index d5aeeb7988d..b6f87438f3b 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==1.0.4"]
+ "requirements": ["aioairzone==1.0.5"]
}
diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py
index 813ead8b6a8..fe259c190ff 100644
--- a/homeassistant/components/airzone/select.py
+++ b/homeassistant/components/airzone/select.py
@@ -20,6 +20,7 @@ from aioairzone.const import (
AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP,
+ AZD_SYSTEMS,
AZD_ZONES,
)
@@ -30,7 +31,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
-from .entity import AirzoneEntity, AirzoneZoneEntity
+from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
@dataclass(frozen=True, kw_only=True)
@@ -85,14 +86,7 @@ def main_zone_options(
return [k for k, v in options.items() if v in modes]
-MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
- AirzoneSelectDescription(
- api_param=API_MODE,
- key=AZD_MODE,
- options_dict=MODE_DICT,
- options_fn=main_zone_options,
- translation_key="modes",
- ),
+SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
@@ -104,6 +98,17 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
)
+MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
+ AirzoneSelectDescription(
+ api_param=API_MODE,
+ key=AZD_MODE,
+ options_dict=MODE_DICT,
+ options_fn=main_zone_options,
+ translation_key="modes",
+ ),
+)
+
+
ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_COLD_ANGLE,
@@ -140,16 +145,37 @@ async def async_setup_entry(
"""Add Airzone select from a config_entry."""
coordinator = entry.runtime_data
+ added_systems: set[str] = set()
added_zones: set[str] = set()
def _async_entity_listener() -> None:
"""Handle additions of select."""
+ entities: list[AirzoneBaseSelect] = []
+
+ systems_data = coordinator.data.get(AZD_SYSTEMS, {})
+ received_systems = set(systems_data)
+ new_systems = received_systems - added_systems
+ if new_systems:
+ entities.extend(
+ AirzoneSystemSelect(
+ coordinator,
+ description,
+ entry,
+ system_id,
+ systems_data.get(system_id),
+ )
+ for system_id in new_systems
+ for description in SYSTEM_SELECT_TYPES
+ if description.key in systems_data.get(system_id)
+ )
+ added_systems.update(new_systems)
+
zones_data = coordinator.data.get(AZD_ZONES, {})
received_zones = set(zones_data)
new_zones = received_zones - added_zones
if new_zones:
- entities: list[AirzoneZoneSelect] = [
+ entities.extend(
AirzoneZoneSelect(
coordinator,
description,
@@ -161,8 +187,8 @@ async def async_setup_entry(
for description in MAIN_ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
and zones_data.get(system_zone_id).get(AZD_MASTER) is True
- ]
- entities += [
+ )
+ entities.extend(
AirzoneZoneSelect(
coordinator,
description,
@@ -173,10 +199,11 @@ async def async_setup_entry(
for system_zone_id in new_zones
for description in ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
- ]
- async_add_entities(entities)
+ )
added_zones.update(new_zones)
+ async_add_entities(entities)
+
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
@@ -203,6 +230,38 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
self._attr_current_option = self._get_current_option()
+class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect):
+ """Define an Airzone System select."""
+
+ def __init__(
+ self,
+ coordinator: AirzoneUpdateCoordinator,
+ description: AirzoneSelectDescription,
+ entry: ConfigEntry,
+ system_id: str,
+ system_data: dict[str, Any],
+ ) -> None:
+ """Initialize."""
+ super().__init__(coordinator, entry, system_data)
+
+ self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
+ self.entity_description = description
+
+ self._attr_options = self.entity_description.options_fn(
+ system_data, description.options_dict
+ )
+
+ self.values_dict = {v: k for k, v in description.options_dict.items()}
+
+ self._async_update_attrs()
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ param = self.entity_description.api_param
+ value = self.entity_description.options_dict[option]
+ await self._async_update_sys_params({param: value})
+
+
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
"""Define an Airzone Zone select."""
diff --git a/homeassistant/components/alarm_control_panel/condition.py b/homeassistant/components/alarm_control_panel/condition.py
new file mode 100644
index 00000000000..b1d3da3488b
--- /dev/null
+++ b/homeassistant/components/alarm_control_panel/condition.py
@@ -0,0 +1,93 @@
+"""Provides conditions for alarm control panels."""
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.condition import (
+ Condition,
+ EntityStateConditionBase,
+ make_entity_state_condition,
+)
+from homeassistant.helpers.entity import get_supported_features
+
+from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
+
+
+def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
+ """Test if an entity supports the specified features."""
+ try:
+ return bool(get_supported_features(hass, entity_id) & features)
+ except HomeAssistantError:
+ return False
+
+
+class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
+ """State condition."""
+
+ _required_features: int
+
+ def entity_filter(self, entities: set[str]) -> set[str]:
+ """Filter entities of this domain with the required features."""
+ entities = super().entity_filter(entities)
+ return {
+ entity_id
+ for entity_id in entities
+ if supports_feature(self._hass, entity_id, self._required_features)
+ }
+
+
+def make_entity_state_required_features_condition(
+ domain: str, to_state: str, required_features: int
+) -> type[EntityStateRequiredFeaturesCondition]:
+ """Create an entity state condition class with required feature filtering."""
+
+ class CustomCondition(EntityStateRequiredFeaturesCondition):
+ """Condition for entity state changes."""
+
+ _domain = domain
+ _states = {to_state}
+ _required_features = required_features
+
+ return CustomCondition
+
+
+CONDITIONS: dict[str, type[Condition]] = {
+ "is_armed": make_entity_state_condition(
+ DOMAIN,
+ {
+ AlarmControlPanelState.ARMED_AWAY,
+ AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
+ AlarmControlPanelState.ARMED_HOME,
+ AlarmControlPanelState.ARMED_NIGHT,
+ AlarmControlPanelState.ARMED_VACATION,
+ },
+ ),
+ "is_armed_away": make_entity_state_required_features_condition(
+ DOMAIN,
+ AlarmControlPanelState.ARMED_AWAY,
+ AlarmControlPanelEntityFeature.ARM_AWAY,
+ ),
+ "is_armed_home": make_entity_state_required_features_condition(
+ DOMAIN,
+ AlarmControlPanelState.ARMED_HOME,
+ AlarmControlPanelEntityFeature.ARM_HOME,
+ ),
+ "is_armed_night": make_entity_state_required_features_condition(
+ DOMAIN,
+ AlarmControlPanelState.ARMED_NIGHT,
+ AlarmControlPanelEntityFeature.ARM_NIGHT,
+ ),
+ "is_armed_vacation": make_entity_state_required_features_condition(
+ DOMAIN,
+ AlarmControlPanelState.ARMED_VACATION,
+ AlarmControlPanelEntityFeature.ARM_VACATION,
+ ),
+ "is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
+ "is_triggered": make_entity_state_condition(
+ DOMAIN, AlarmControlPanelState.TRIGGERED
+ ),
+}
+
+
+async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
+ """Return the alarm control panel conditions."""
+ return CONDITIONS
diff --git a/homeassistant/components/alarm_control_panel/conditions.yaml b/homeassistant/components/alarm_control_panel/conditions.yaml
new file mode 100644
index 00000000000..12c5b700b32
--- /dev/null
+++ b/homeassistant/components/alarm_control_panel/conditions.yaml
@@ -0,0 +1,52 @@
+.condition_common: &condition_common
+ target:
+ entity:
+ domain: alarm_control_panel
+ fields: &condition_common_fields
+ behavior:
+ required: true
+ default: any
+ selector:
+ select:
+ translation_key: condition_behavior
+ options:
+ - all
+ - any
+
+is_armed: *condition_common
+
+is_armed_away:
+ fields: *condition_common_fields
+ target:
+ entity:
+ domain: alarm_control_panel
+ supported_features:
+ - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
+
+is_armed_home:
+ fields: *condition_common_fields
+ target:
+ entity:
+ domain: alarm_control_panel
+ supported_features:
+ - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
+
+is_armed_night:
+ fields: *condition_common_fields
+ target:
+ entity:
+ domain: alarm_control_panel
+ supported_features:
+ - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
+
+is_armed_vacation:
+ fields: *condition_common_fields
+ target:
+ entity:
+ domain: alarm_control_panel
+ supported_features:
+ - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
+
+is_disarmed: *condition_common
+
+is_triggered: *condition_common
diff --git a/homeassistant/components/alarm_control_panel/icons.json b/homeassistant/components/alarm_control_panel/icons.json
index 0fbaeb59cf5..b7db7d4fffd 100644
--- a/homeassistant/components/alarm_control_panel/icons.json
+++ b/homeassistant/components/alarm_control_panel/icons.json
@@ -1,4 +1,27 @@
{
+ "conditions": {
+ "is_armed": {
+ "condition": "mdi:shield"
+ },
+ "is_armed_away": {
+ "condition": "mdi:shield-lock"
+ },
+ "is_armed_home": {
+ "condition": "mdi:shield-home"
+ },
+ "is_armed_night": {
+ "condition": "mdi:shield-moon"
+ },
+ "is_armed_vacation": {
+ "condition": "mdi:shield-airplane"
+ },
+ "is_disarmed": {
+ "condition": "mdi:shield-off"
+ },
+ "is_triggered": {
+ "condition": "mdi:bell-ring"
+ }
+ },
"entity_component": {
"_": {
"default": "mdi:shield",
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 94567d69f3e..88d203193c3 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -1,8 +1,82 @@
{
"common": {
+ "condition_behavior_description": "How the state should match on the targeted alarms.",
+ "condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
+ "conditions": {
+ "is_armed": {
+ "description": "Tests if one or more alarms are armed.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is armed"
+ },
+ "is_armed_away": {
+ "description": "Tests if one or more alarms are armed in away mode.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is armed away"
+ },
+ "is_armed_home": {
+ "description": "Tests if one or more alarms are armed in home mode.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is armed home"
+ },
+ "is_armed_night": {
+ "description": "Tests if one or more alarms are armed in night mode.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is armed night"
+ },
+ "is_armed_vacation": {
+ "description": "Tests if one or more alarms are armed in vacation mode.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is armed vacation"
+ },
+ "is_disarmed": {
+ "description": "Tests if one or more alarms are disarmed.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is disarmed"
+ },
+ "is_triggered": {
+ "description": "Tests if one or more alarms are triggered.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
+ "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If an alarm is triggered"
+ }
+ },
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -76,6 +150,12 @@
}
},
"selector": {
+ "condition_behavior": {
+ "options": {
+ "all": "All",
+ "any": "Any"
+ }
+ },
"trigger_behavior": {
"options": {
"any": "Any",
diff --git a/homeassistant/components/alarm_control_panel/trigger.py b/homeassistant/components/alarm_control_panel/trigger.py
index 19302900823..d970ea9ec6b 100644
--- a/homeassistant/components/alarm_control_panel/trigger.py
+++ b/homeassistant/components/alarm_control_panel/trigger.py
@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
- """Get the device class of an entity or UNDEFINED if not found."""
+ """Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
- """Create an entity state trigger class."""
+ """Create an entity state trigger class with required feature filtering."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index 7fde54194e0..2f8acbc754c 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
+import dateutil
+
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
@@ -179,6 +181,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
LAST_S_TEST: SensorEntityDescription(
key=LAST_S_TEST,
translation_key="last_self_test",
+ device_class=SensorDeviceClass.TIMESTAMP,
),
"lastxfer": SensorEntityDescription(
key="lastxfer",
@@ -232,6 +235,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
+ device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"maxlinev": SensorEntityDescription(
@@ -365,6 +369,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"statflag": SensorEntityDescription(
@@ -416,16 +421,19 @@ SENSORS: dict[str, SensorEntityDescription] = {
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
+ device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
+ device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
+ device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@@ -529,7 +537,13 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value = None
return
- self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
+ data = self.coordinator.data[key]
+
+ if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
+ self._attr_native_value = dateutil.parser.parse(data)
+ return
+
+ self._attr_native_value, inferred_unit = infer_unit(data)
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit
diff --git a/homeassistant/components/assist_pipeline/audio_enhancer.py b/homeassistant/components/assist_pipeline/audio_enhancer.py
index 18f00d58d8a..1fabc7790e7 100644
--- a/homeassistant/components/assist_pipeline/audio_enhancer.py
+++ b/homeassistant/components/assist_pipeline/audio_enhancer.py
@@ -3,9 +3,8 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
import logging
-import math
-from pysilero_vad import SileroVoiceActivityDetector
+from pymicro_vad import MicroVad
from pyspeex_noise import AudioProcessor
from .const import BYTES_PER_CHUNK
@@ -43,8 +42,8 @@ class AudioEnhancer(ABC):
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
-class SileroVadSpeexEnhancer(AudioEnhancer):
- """Audio enhancer that runs Silero VAD and speex."""
+class MicroVadSpeexEnhancer(AudioEnhancer):
+ """Audio enhancer that runs microVAD and speex."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
@@ -70,49 +69,21 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
self.noise_suppression,
)
- self.vad: SileroVoiceActivityDetector | None = None
-
- # We get 10ms chunks but Silero works on 32ms chunks, so we have to
- # buffer audio. The previous speech probability is used until enough
- # audio has been buffered.
- self._vad_buffer: bytearray | None = None
- self._vad_buffer_chunks = 0
- self._vad_buffer_chunk_idx = 0
- self._last_speech_probability: float | None = None
+ self.vad: MicroVad | None = None
if self.is_vad_enabled:
- self.vad = SileroVoiceActivityDetector()
-
- # VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
- self._vad_buffer_chunks = int(
- math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
- )
- self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
- self._vad_buffer = bytearray(self.vad.chunk_bytes())
- _LOGGER.debug("Initialized Silero VAD")
+ self.vad = MicroVad()
+ _LOGGER.debug("Initialized microVAD")
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
+ speech_probability: float | None = None
+
assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None:
# Run VAD
- assert self._vad_buffer is not None
- start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
- self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
-
- self._vad_buffer_chunk_idx += 1
- if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
- # We have enough data to run Silero VAD (32 ms)
- self._last_speech_probability = self.vad.process_chunk(
- self._vad_buffer[: self.vad.chunk_bytes()]
- )
-
- # Copy leftover audio that wasn't processed to start
- self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
- -self._vad_leftover_bytes :
- ]
- self._vad_buffer_chunk_idx = 0
+ speech_probability = self.vad.Process10ms(audio)
if self.audio_processor is not None:
# Run noise suppression and auto gain
@@ -121,5 +92,5 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
return EnhancedAudioChunk(
audio=audio,
timestamp_ms=timestamp_ms,
- speech_probability=self._last_speech_probability,
+ speech_probability=speech_probability,
)
diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json
index 98804877c2e..d88e4352130 100644
--- a/homeassistant/components/assist_pipeline/manifest.json
+++ b/homeassistant/components/assist_pipeline/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["pysilero-vad==3.1.0", "pyspeex-noise==1.0.2"]
+ "requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
}
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index abfc4e72782..0948413d4cc 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -55,7 +55,7 @@ from homeassistant.util import (
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
-from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
+from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .const import (
ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK,
@@ -633,7 +633,7 @@ class PipelineRun:
# Initialize with audio settings
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
# Default audio enhancer
- self.audio_enhancer = SileroVadSpeexEnhancer(
+ self.audio_enhancer = MicroVadSpeexEnhancer(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
self.audio_settings.is_vad_enabled,
diff --git a/homeassistant/components/assist_satellite/condition.py b/homeassistant/components/assist_satellite/condition.py
new file mode 100644
index 00000000000..0c0a402d6f5
--- /dev/null
+++ b/homeassistant/components/assist_satellite/condition.py
@@ -0,0 +1,23 @@
+"""Provides conditions for assist satellites."""
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.condition import Condition, make_entity_state_condition
+
+from .const import DOMAIN
+from .entity import AssistSatelliteState
+
+CONDITIONS: dict[str, type[Condition]] = {
+ "is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
+ "is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
+ "is_processing": make_entity_state_condition(
+ DOMAIN, AssistSatelliteState.PROCESSING
+ ),
+ "is_responding": make_entity_state_condition(
+ DOMAIN, AssistSatelliteState.RESPONDING
+ ),
+}
+
+
+async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
+ """Return the assist satellite conditions."""
+ return CONDITIONS
diff --git a/homeassistant/components/assist_satellite/conditions.yaml b/homeassistant/components/assist_satellite/conditions.yaml
new file mode 100644
index 00000000000..eeb7f02b913
--- /dev/null
+++ b/homeassistant/components/assist_satellite/conditions.yaml
@@ -0,0 +1,19 @@
+.condition_common: &condition_common
+ target:
+ entity:
+ domain: assist_satellite
+ fields:
+ behavior:
+ required: true
+ default: any
+ selector:
+ select:
+ translation_key: condition_behavior
+ options:
+ - all
+ - any
+
+is_idle: *condition_common
+is_listening: *condition_common
+is_processing: *condition_common
+is_responding: *condition_common
diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json
index 975b943416d..c4f15d320de 100644
--- a/homeassistant/components/assist_satellite/icons.json
+++ b/homeassistant/components/assist_satellite/icons.json
@@ -1,4 +1,18 @@
{
+ "conditions": {
+ "is_idle": {
+ "condition": "mdi:chat-sleep"
+ },
+ "is_listening": {
+ "condition": "mdi:chat-question"
+ },
+ "is_processing": {
+ "condition": "mdi:chat-processing"
+ },
+ "is_responding": {
+ "condition": "mdi:chat-alert"
+ }
+ },
"entity_component": {
"_": {
"default": "mdi:account-voice"
diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json
index 95ce20a851c..4680df87f33 100644
--- a/homeassistant/components/assist_satellite/strings.json
+++ b/homeassistant/components/assist_satellite/strings.json
@@ -1,8 +1,52 @@
{
"common": {
+ "condition_behavior_description": "How the state should match on the targeted Assist satellites.",
+ "condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
+ "conditions": {
+ "is_idle": {
+ "description": "Tests if one or more Assist satellites are idle.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
+ "name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a satellite is idle"
+ },
+ "is_listening": {
+ "description": "Tests if one or more Assist satellites are listening.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
+ "name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a satellite is listening"
+ },
+ "is_processing": {
+ "description": "Tests if one or more Assist satellites are processing.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
+ "name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a satellite is processing"
+ },
+ "is_responding": {
+ "description": "Tests if one or more Assist satellites are responding.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
+ "name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a satellite is responding"
+ }
+ },
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -21,6 +65,12 @@
"sentences": "Sentences"
}
},
+ "condition_behavior": {
+ "options": {
+ "all": "All",
+ "any": "Any"
+ }
+ },
"trigger_behavior": {
"options": {
"any": "Any",
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 5ce6fca7c2e..d56ca509a74 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -123,7 +123,11 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
+ "alarm_control_panel",
+ "assist_satellite",
+ "fan",
"light",
+ "siren",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
@@ -598,6 +602,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced labels."""
referenced = self.action_script.referenced_labels
+ if self._cond_func is not None:
+ for conf in self._cond_func.config:
+ referenced |= condition.async_extract_targets(conf, ATTR_LABEL_ID)
+
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@@ -607,6 +615,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced floors."""
referenced = self.action_script.referenced_floors
+ if self._cond_func is not None:
+ for conf in self._cond_func.config:
+ referenced |= condition.async_extract_targets(conf, ATTR_FLOOR_ID)
+
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
return referenced
@@ -616,6 +628,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced areas."""
referenced = self.action_script.referenced_areas
+ if self._cond_func is not None:
+ for conf in self._cond_func.config:
+ referenced |= condition.async_extract_targets(conf, ATTR_AREA_ID)
+
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
return referenced
diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py
index 83eb8076fef..5943ec8f855 100644
--- a/homeassistant/components/azure_service_bus/notify.py
+++ b/homeassistant/components/azure_service_bus/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
+from typing import Any
from azure.servicebus import ServiceBusMessage
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender
@@ -92,7 +93,7 @@ class ServiceBusNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
- async def async_send_message(self, message, **kwargs):
+ async def async_send_message(self, message: str, **kwargs: Any) -> None:
"""Send a message."""
dto = {ATTR_ASB_MESSAGE: message}
diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json
index a457fa667ed..966e2adb5a1 100644
--- a/homeassistant/components/binary_sensor/icons.json
+++ b/homeassistant/components/binary_sensor/icons.json
@@ -85,9 +85,9 @@
}
},
"moving": {
- "default": "mdi:arrow-right",
+ "default": "mdi:octagon",
"state": {
- "on": "mdi:octagon"
+ "on": "mdi:arrow-right"
}
},
"occupancy": {
diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py
index 5120a7a3c98..14cddf41e45 100644
--- a/homeassistant/components/blebox/sensor.py
+++ b/homeassistant/components/blebox/sensor.py
@@ -1,5 +1,7 @@
"""BleBox sensor entities."""
+from datetime import datetime
+
import blebox_uniapi.sensor
from homeassistant.components.sensor import (
@@ -146,7 +148,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
return self._feature.native_value
@property
- def last_reset(self):
+ def last_reset(self) -> datetime | None:
"""Return the time when the sensor was last reset, if implemented."""
native_implementation = getattr(self._feature, "last_reset", None)
diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py
index 0743d027d8d..873e3b30a36 100644
--- a/homeassistant/components/blueprint/websocket_api.py
+++ b/homeassistant/components/blueprint/websocket_api.py
@@ -64,6 +64,7 @@ def _ws_with_blueprint_domain(
return with_domain_blueprints
+@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/list",
@@ -97,6 +98,7 @@ async def ws_list_blueprints(
connection.send_result(msg["id"], results)
+@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/import",
@@ -150,6 +152,7 @@ async def ws_import_blueprint(
)
+@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/save",
@@ -206,6 +209,7 @@ async def ws_save_blueprint(
)
+@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/delete",
@@ -233,6 +237,7 @@ async def ws_delete_blueprint(
)
+@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/substitute",
diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index eaa0c1eebb6..1abe376826b 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -1,5 +1,6 @@
"""The BSB-Lan integration."""
+import asyncio
import dataclasses
from bsblan import (
@@ -77,12 +78,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
bsblan = BSBLAN(config, session)
try:
- # Initialize the client first - this sets up internal caches and validates the connection
+ # Initialize the client first - this sets up internal caches and validates
+ # the connection by fetching firmware version
await bsblan.initialize()
- # Fetch all required device metadata
- device = await bsblan.device()
- info = await bsblan.info()
- static = await bsblan.static_values()
+
+ # Fetch device metadata in parallel for faster startup
+ device, info, static = await asyncio.gather(
+ bsblan.device(),
+ bsblan.info(),
+ bsblan.static_values(),
+ )
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -110,10 +115,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
- # Perform first refresh of both coordinators
+ # Perform first refresh of fast coordinator (required for entities)
await fast_coordinator.async_config_entry_first_refresh()
- # Try to refresh slow coordinator, but don't fail if DHW is not available
+ # Refresh slow coordinator - don't fail if DHW is not available
# This allows the integration to work even if the device doesn't support DHW
await slow_coordinator.async_refresh()
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index 71f2776d951..44767ff7bff 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -111,11 +111,17 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return None
return self.coordinator.data.state.target_temperature.value
+ @property
+ def _hvac_mode_value(self) -> int | str | None:
+ """Return the raw hvac_mode value from the coordinator."""
+ if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
+ return None
+ return hvac_mode.value
+
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
- hvac_mode_value = self.coordinator.data.state.hvac_mode.value
- if hvac_mode_value is None:
+ if (hvac_mode_value := self._hvac_mode_value) is None:
return None
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
@@ -125,9 +131,8 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
- hvac_mode_value = self.coordinator.data.state.hvac_mode.value
# BSB-Lan mode 2 is eco/reduced mode
- if hvac_mode_value == 2:
+ if self._hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE
diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py
index 6b1ae70b338..b39376f6f02 100644
--- a/homeassistant/components/bsblan/coordinator.py
+++ b/homeassistant/components/bsblan/coordinator.py
@@ -2,7 +2,6 @@
from dataclasses import dataclass
from datetime import timedelta
-from random import randint
from bsblan import (
BSBLAN,
@@ -23,6 +22,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
+# Filter lists for optimized API calls - only fetch parameters we actually use
+# This significantly reduces response time (~0.2s per parameter saved)
+STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
+SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
+DHW_STATE_INCLUDE = [
+ "operating_mode",
+ "nominal_setpoint",
+ "dhw_actual_value_top_temperature",
+]
+DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
+
@dataclass
class BSBLanFastData:
@@ -80,26 +90,18 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry,
client,
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
- update_interval=self._get_update_interval(),
+ update_interval=SCAN_INTERVAL_FAST,
)
- def _get_update_interval(self) -> timedelta:
- """Get the update interval with a random offset.
-
- Add a random number of seconds to avoid timeouts when
- the BSB-Lan device is already/still busy retrieving data,
- e.g. for MQTT or internal logging.
- """
- return SCAN_INTERVAL_FAST + timedelta(seconds=randint(1, 8))
-
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
- # Fetch fast-changing data (state, sensor, DHW state)
- state = await self.client.state()
- sensor = await self.client.sensor()
- dhw = await self.client.hot_water_state()
+ # Use include filtering to only fetch parameters we actually use
+ # This reduces response time significantly (~0.2s per parameter)
+ state = await self.client.state(include=STATE_INCLUDE)
+ sensor = await self.client.sensor(include=SENSOR_INCLUDE)
+ dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -111,9 +113,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
f"Error while establishing connection with BSB-Lan device at {host}"
) from err
- # Update the interval with random jitter for next update
- self.update_interval = self._get_update_interval()
-
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -143,8 +142,8 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""Fetch slow-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
- # Fetch slow-changing configuration data
- dhw_config = await self.client.hot_water_config()
+ # Use include filtering to only fetch parameters we actually use
+ dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:
diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py
index b2effc9f3f4..5f5203ef8d0 100644
--- a/homeassistant/components/bsblan/entity.py
+++ b/homeassistant/components/bsblan/entity.py
@@ -29,7 +29,11 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
- model=data.info.device_identification.value,
+ model=(
+ data.info.device_identification.value
+ if data.info.device_identification
+ else None
+ ),
sw_version=data.device.version,
configuration_url=f"http://{host}",
)
diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json
index 4545e601719..fe581ef062f 100644
--- a/homeassistant/components/bsblan/manifest.json
+++ b/homeassistant/components/bsblan/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
- "requirements": ["python-bsblan==3.1.6"],
+ "requirements": ["python-bsblan==4.1.0"],
"zeroconf": [
{
"name": "bsb-lan*",
diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json
index ae91ff19239..ca9744d5b63 100644
--- a/homeassistant/components/bthome/manifest.json
+++ b/homeassistant/components/bthome/manifest.json
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
- "requirements": ["bthome-ble==3.17.0"]
+ "requirements": ["bthome-ble==3.16.0"]
}
diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json
index 804a3a4b04f..e2faf13658c 100644
--- a/homeassistant/components/calendar/icons.json
+++ b/homeassistant/components/calendar/icons.json
@@ -15,5 +15,13 @@
"get_events": {
"service": "mdi:calendar-month"
}
+ },
+ "triggers": {
+ "event_ended": {
+ "trigger": "mdi:calendar-end"
+ },
+ "event_started": {
+ "trigger": "mdi:calendar-start"
+ }
}
}
diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json
index c8117140d9d..b4169132e86 100644
--- a/homeassistant/components/calendar/strings.json
+++ b/homeassistant/components/calendar/strings.json
@@ -45,6 +45,14 @@
"title": "Detected use of deprecated action calendar.list_events"
}
},
+ "selector": {
+ "trigger_offset_type": {
+ "options": {
+ "after": "After",
+ "before": "Before"
+ }
+ }
+ },
"services": {
"create_event": {
"description": "Adds a new calendar event.",
@@ -103,5 +111,35 @@
"name": "Get events"
}
},
- "title": "Calendar"
+ "title": "Calendar",
+ "triggers": {
+ "event_ended": {
+ "description": "Triggers when a calendar event ends.",
+ "fields": {
+ "offset": {
+ "description": "Offset from the end of the event.",
+ "name": "Offset"
+ },
+ "offset_type": {
+ "description": "Whether to trigger before or after the end of the event, if an offset is defined.",
+ "name": "Offset type"
+ }
+ },
+ "name": "Calendar event ended"
+ },
+ "event_started": {
+ "description": "Triggers when a calendar event starts.",
+ "fields": {
+ "offset": {
+ "description": "Offset from the start of the event.",
+ "name": "Offset"
+ },
+ "offset_type": {
+ "description": "Whether to trigger before or after the start of the event, if an offset is defined.",
+ "name": "Offset type"
+ }
+ },
+ "name": "Calendar event started"
+ }
+ }
}
diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py
index ec96d23a424..18ab33516e7 100644
--- a/homeassistant/components/calendar/trigger.py
+++ b/homeassistant/components/calendar/trigger.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
@@ -10,8 +11,15 @@ from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
-from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_ENTITY_ID,
+ CONF_EVENT,
+ CONF_OFFSET,
+ CONF_OPTIONS,
+ CONF_TARGET,
+)
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
@@ -20,12 +28,13 @@ from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_time_interval,
)
+from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import CalendarEntity, CalendarEvent
-from .const import DATA_COMPONENT
+from .const import DATA_COMPONENT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,19 +42,35 @@ EVENT_START = "start"
EVENT_END = "end"
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
+CONF_OFFSET_TYPE = "offset_type"
+OFFSET_TYPE_BEFORE = "before"
+OFFSET_TYPE_AFTER = "after"
-_OPTIONS_SCHEMA_DICT = {
+
+_SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA = {
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
-_CONFIG_SCHEMA = vol.Schema(
+_SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA = vol.Schema(
{
- vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
+ vol.Required(CONF_OPTIONS): _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA,
},
)
+_EVENT_TRIGGER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OPTIONS, default={}): {
+ vol.Required(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
+ vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In(
+ {OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER}
+ ),
+ },
+ vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
+ }
+)
+
# mypy: disallow-any-generics
@@ -55,6 +80,7 @@ class QueuedCalendarEvent:
trigger_time: datetime.datetime
event: CalendarEvent
+ entity_id: str
@dataclass
@@ -94,7 +120,7 @@ class Timespan:
return f"[{self.start}, {self.end})"
-type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]]
+type EventFetcher = Callable[[Timespan], Awaitable[list[tuple[str, CalendarEvent]]]]
type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]]
@@ -110,15 +136,24 @@ def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity:
return entity
-def event_fetcher(hass: HomeAssistant, entity_id: str) -> EventFetcher:
+def event_fetcher(hass: HomeAssistant, entity_ids: set[str]) -> EventFetcher:
"""Build an async_get_events wrapper to fetch events during a time span."""
- async def async_get_events(timespan: Timespan) -> list[CalendarEvent]:
+ async def async_get_events(timespan: Timespan) -> list[tuple[str, CalendarEvent]]:
"""Return events active in the specified time span."""
- entity = get_entity(hass, entity_id)
# Expand by one second to make the end time exclusive
end_time = timespan.end + datetime.timedelta(seconds=1)
- return await entity.async_get_events(hass, timespan.start, end_time)
+
+ events: list[tuple[str, CalendarEvent]] = []
+ for entity_id in entity_ids:
+ entity = get_entity(hass, entity_id)
+ events.extend(
+ (entity_id, event)
+ for event in await entity.async_get_events(
+ hass, timespan.start, end_time
+ )
+ )
+ return events
return async_get_events
@@ -142,12 +177,11 @@ def queued_event_fetcher(
# Example: For an EVENT_END trigger the event may start during this
# time span, but need to be triggered later when the end happens.
results = []
- for trigger_time, event in zip(
- map(get_trigger_time, active_events), active_events, strict=False
- ):
+ for entity_id, event in active_events:
+ trigger_time = get_trigger_time(event)
if trigger_time not in offset_timespan:
continue
- results.append(QueuedCalendarEvent(trigger_time + offset, event))
+ results.append(QueuedCalendarEvent(trigger_time + offset, event, entity_id))
_LOGGER.debug(
"Scan events @ %s%s found %s eligible of %s active",
@@ -240,6 +274,7 @@ class CalendarEventListener:
_LOGGER.debug("Dispatching event: %s", queued_event.event)
payload = {
**self._trigger_payload,
+ ATTR_ENTITY_ID: queued_event.entity_id,
"calendar_event": queued_event.event.as_dict(),
}
self._action_runner(payload, "calendar event state change")
@@ -260,8 +295,77 @@ class CalendarEventListener:
self._listen_next_calendar_event()
-class EventTrigger(Trigger):
- """Calendar event trigger."""
+class TargetCalendarEventListener(TargetEntityChangeTracker):
+ """Helper class to listen to calendar events for target entity changes."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ target_selection: TargetSelection,
+ event_type: str,
+ offset: datetime.timedelta,
+ run_action: TriggerActionRunner,
+ ) -> None:
+ """Initialize the state change tracker."""
+
+ def entity_filter(entities: set[str]) -> set[str]:
+ return {
+ entity_id
+ for entity_id in entities
+ if split_entity_id(entity_id)[0] == DOMAIN
+ }
+
+ super().__init__(hass, target_selection, entity_filter)
+ self._event_type = event_type
+ self._offset = offset
+ self._run_action = run_action
+ self._trigger_data = {
+ "event": event_type,
+ "offset": offset,
+ }
+
+ self._pending_listener_task: asyncio.Task[None] | None = None
+ self._calendar_event_listener: CalendarEventListener | None = None
+
+ @callback
+ def _handle_entities_update(self, tracked_entities: set[str]) -> None:
+ """Restart the listeners when the list of entities of the tracked targets is updated."""
+ if self._pending_listener_task:
+ self._pending_listener_task.cancel()
+ self._pending_listener_task = self._hass.async_create_task(
+ self._start_listening(tracked_entities)
+ )
+
+ async def _start_listening(self, tracked_entities: set[str]) -> None:
+ """Start listening for calendar events."""
+ _LOGGER.debug("Tracking events for calendars: %s", tracked_entities)
+ if self._calendar_event_listener:
+ self._calendar_event_listener.async_detach()
+ self._calendar_event_listener = CalendarEventListener(
+ self._hass,
+ self._run_action,
+ self._trigger_data,
+ queued_event_fetcher(
+ event_fetcher(self._hass, tracked_entities),
+ self._event_type,
+ self._offset,
+ ),
+ )
+ await self._calendar_event_listener.async_attach()
+
+ def _unsubscribe(self) -> None:
+ """Unsubscribe from all events."""
+ super()._unsubscribe()
+ if self._pending_listener_task:
+ self._pending_listener_task.cancel()
+ self._pending_listener_task = None
+ if self._calendar_event_listener:
+ self._calendar_event_listener.async_detach()
+ self._calendar_event_listener = None
+
+
+class SingleEntityEventTrigger(Trigger):
+ """Legacy single calendar entity event trigger."""
_options: dict[str, Any]
@@ -271,7 +375,7 @@ class EventTrigger(Trigger):
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
- complete_config, _OPTIONS_SCHEMA_DICT
+ complete_config, _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
@@ -280,7 +384,7 @@ class EventTrigger(Trigger):
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
- return cast(ConfigType, _CONFIG_SCHEMA(config))
+ return cast(ConfigType, _SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
@@ -311,15 +415,72 @@ class EventTrigger(Trigger):
run_action,
trigger_data,
queued_event_fetcher(
- event_fetcher(self._hass, entity_id), event_type, offset
+ event_fetcher(self._hass, {entity_id}), event_type, offset
),
)
await listener.async_attach()
return listener.async_detach
+class EventTrigger(Trigger):
+ """Calendar event trigger."""
+
+ _options: dict[str, Any]
+ _event_type: str
+
+ @classmethod
+ async def async_validate_config(
+ cls, hass: HomeAssistant, config: ConfigType
+ ) -> ConfigType:
+ """Validate config."""
+ return cast(ConfigType, _EVENT_TRIGGER_SCHEMA(config))
+
+ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
+ """Initialize trigger."""
+ super().__init__(hass, config)
+
+ if TYPE_CHECKING:
+ assert config.target is not None
+ assert config.options is not None
+ self._target = config.target
+ self._options = config.options
+
+ async def async_attach_runner(
+ self, run_action: TriggerActionRunner
+ ) -> CALLBACK_TYPE:
+ """Attach a trigger."""
+
+ offset = self._options[CONF_OFFSET]
+ offset_type = self._options[CONF_OFFSET_TYPE]
+
+ if offset_type == OFFSET_TYPE_BEFORE:
+ offset = -offset
+
+ target_selection = TargetSelection(self._target)
+ if not target_selection.has_any_target:
+ raise HomeAssistantError(f"No target defined in {self._target}")
+ listener = TargetCalendarEventListener(
+ self._hass, target_selection, self._event_type, offset, run_action
+ )
+ return listener.async_setup()
+
+
+class EventStartedTrigger(EventTrigger):
+ """Calendar event started trigger."""
+
+ _event_type = EVENT_START
+
+
+class EventEndedTrigger(EventTrigger):
+ """Calendar event ended trigger."""
+
+ _event_type = EVENT_END
+
+
TRIGGERS: dict[str, type[Trigger]] = {
- "_": EventTrigger,
+ "_": SingleEntityEventTrigger,
+ "event_started": EventStartedTrigger,
+ "event_ended": EventEndedTrigger,
}
diff --git a/homeassistant/components/calendar/triggers.yaml b/homeassistant/components/calendar/triggers.yaml
new file mode 100644
index 00000000000..37599b4515a
--- /dev/null
+++ b/homeassistant/components/calendar/triggers.yaml
@@ -0,0 +1,27 @@
+.trigger_common: &trigger_common
+ target:
+ entity:
+ domain: calendar
+ fields:
+ offset:
+ required: true
+ default:
+ days: 0
+ hours: 0
+ minutes: 0
+ seconds: 0
+ selector:
+ duration:
+ enable_day: true
+ offset_type:
+ required: true
+ default: before
+ selector:
+ select:
+ translation_key: trigger_offset_type
+ options:
+ - before
+ - after
+
+event_started: *trigger_common
+event_ended: *trigger_common
diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py
index 2f76ed8f65a..888af58b798 100644
--- a/homeassistant/components/cisco_webex_teams/notify.py
+++ b/homeassistant/components/cisco_webex_teams/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import voluptuous as vol
from webexpythonsdk import ApiError, WebexAPI, exceptions
@@ -51,7 +52,7 @@ class CiscoWebexNotificationService(BaseNotificationService):
self.room = room
self.client = client
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
title = ""
diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py
index 5a08ccd7988..632b76bc7be 100644
--- a/homeassistant/components/clicksend_tts/notify.py
+++ b/homeassistant/components/clicksend_tts/notify.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
+from typing import Any
import requests
import voluptuous as vol
@@ -81,7 +82,7 @@ class ClicksendNotificationService(BaseNotificationService):
self.language = config[CONF_LANGUAGE]
self.voice = config[CONF_VOICE]
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a voice call to a user."""
data = {
"messages": [
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 410029c2716..17a1ad4800d 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -50,7 +50,6 @@ from . import (
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
- CONF_ACCOUNTS_SERVER,
CONF_ACME_SERVER,
CONF_ALEXA,
CONF_ALIASES,
@@ -138,7 +137,6 @@ _BASE_CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
- vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_API_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 2922d62792e..f69533aabe4 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -76,7 +76,6 @@ CONF_GOOGLE_ACTIONS = "google_actions"
CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
-CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index bcf2d015808..1b8766b83df 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
- "requirements": ["hass-nabucasa==1.7.0"],
+ "requirements": ["hass-nabucasa==1.11.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json
index 8c32dcc8e45..256ce7b81c6 100644
--- a/homeassistant/components/compit/manifest.json
+++ b/homeassistant/components/compit/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
- "requirements": ["compit-inext-api==0.3.4"]
+ "requirements": ["compit-inext-api==0.4.2"]
}
diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py
index 61cf2aebb31..f4498c43ab6 100644
--- a/homeassistant/components/concord232/alarm_control_panel.py
+++ b/homeassistant/components/concord232/alarm_control_panel.py
@@ -49,11 +49,11 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Concord232 alarm control panel platform."""
- name = config[CONF_NAME]
- code = config.get(CONF_CODE)
- mode = config[CONF_MODE]
- host = config[CONF_HOST]
- port = config[CONF_PORT]
+ name: str = config[CONF_NAME]
+ code: str | None = config.get(CONF_CODE)
+ mode: str = config[CONF_MODE]
+ host: str = config[CONF_HOST]
+ port: int = config[CONF_PORT]
url = f"http://{host}:{port}"
@@ -72,7 +72,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
- def __init__(self, url, name, code, mode):
+ def __init__(self, url: str, name: str, code: str | None, mode: str) -> None:
"""Initialize the Concord232 alarm panel."""
self._attr_name = name
@@ -125,7 +125,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
return
self._alarm.arm("away")
- def _validate_code(self, code, state):
+ def _validate_code(self, code: str | None, state: AlarmControlPanelState) -> bool:
"""Validate given code."""
if self._code is None:
return True
diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py
index a60cf31a646..cc4d3bb92bd 100644
--- a/homeassistant/components/concord232/binary_sensor.py
+++ b/homeassistant/components/concord232/binary_sensor.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import logging
+from typing import Any
from concord232 import client as concord232_client
import requests
@@ -29,8 +30,7 @@ CONF_ZONE_TYPES = "zone_types"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "Alarm"
-DEFAULT_PORT = "5007"
-DEFAULT_SSL = False
+DEFAULT_PORT = 5007
SCAN_INTERVAL = datetime.timedelta(seconds=10)
@@ -56,10 +56,10 @@ def setup_platform(
) -> None:
"""Set up the Concord232 binary sensor platform."""
- host = config[CONF_HOST]
- port = config[CONF_PORT]
- exclude = config[CONF_EXCLUDE_ZONES]
- zone_types = config[CONF_ZONE_TYPES]
+ host: str = config[CONF_HOST]
+ port: int = config[CONF_PORT]
+ exclude: list[int] = config[CONF_EXCLUDE_ZONES]
+ zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES]
sensors = []
try:
@@ -84,7 +84,6 @@ def setup_platform(
if zone["number"] not in exclude:
sensors.append(
Concord232ZoneSensor(
- hass,
client,
zone,
zone_types.get(zone["number"], get_opening_type(zone)),
@@ -110,26 +109,25 @@ def get_opening_type(zone):
class Concord232ZoneSensor(BinarySensorEntity):
"""Representation of a Concord232 zone as a sensor."""
- def __init__(self, hass, client, zone, zone_type):
+ def __init__(
+ self,
+ client: concord232_client.Client,
+ zone: dict[str, Any],
+ zone_type: BinarySensorDeviceClass,
+ ) -> None:
"""Initialize the Concord232 binary sensor."""
- self._hass = hass
self._client = client
self._zone = zone
self._number = zone["number"]
- self._zone_type = zone_type
+ self._attr_device_class = zone_type
@property
- def device_class(self):
- """Return the class of this sensor, from DEVICE_CLASSES."""
- return self._zone_type
-
- @property
- def name(self):
+ def name(self) -> str:
"""Return the name of the binary sensor."""
return self._zone["name"]
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
# True means "faulted" or "open" or "abnormal state"
return bool(self._zone["state"] != "Normal")
@@ -145,5 +143,5 @@ class Concord232ZoneSensor(BinarySensorEntity):
if hasattr(self._client, "zones"):
self._zone = next(
- (x for x in self._client.zones if x["number"] == self._number), None
+ x for x in self._client.zones if x["number"] == self._number
)
diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py
index a1ce5645d6b..3a593906bcd 100644
--- a/homeassistant/components/config/entity_registry.py
+++ b/homeassistant/components/config/entity_registry.py
@@ -11,13 +11,11 @@ from homeassistant import config_entries
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id
from homeassistant.helpers.json import json_dumps
_LOGGER = logging.getLogger(__name__)
@@ -351,26 +349,12 @@ def websocket_get_automatic_entity_ids(
if not (entry := registry.entities.get(entity_id)):
automatic_entity_ids[entity_id] = None
continue
- try:
- suggested = async_get_entity_suggested_object_id(hass, entity_id)
- except HomeAssistantError as err:
- # This is raised if the entity has no object.
- _LOGGER.debug(
- "Unable to get suggested object ID for %s, entity ID: %s (%s)",
- entry.entity_id,
- entity_id,
- err,
- )
- automatic_entity_ids[entity_id] = None
- continue
- suggested_entity_id = registry.async_generate_entity_id(
- entry.domain,
- suggested or f"{entry.platform}_{entry.unique_id}",
- current_entity_id=entity_id,
+ new_entity_id = registry.async_regenerate_entity_id(
+ entry,
reserved_entity_ids=reserved_entity_ids,
)
- automatic_entity_ids[entity_id] = suggested_entity_id
- reserved_entity_ids.add(suggested_entity_id)
+ automatic_entity_ids[entity_id] = new_entity_id
+ reserved_entity_ids.add(new_entity_id)
connection.send_message(
websocket_api.result_message(msg["id"], automatic_entity_ids)
diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json
index 3d0e4cf8e8a..2fac380007f 100644
--- a/homeassistant/components/datadog/manifest.json
+++ b/homeassistant/components/datadog/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
- "quality_scale": "legacy",
"requirements": ["datadog==0.52.0"]
}
diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py
index 873f5cde284..6edc9beaf38 100644
--- a/homeassistant/components/deconz/const.py
+++ b/homeassistant/components/deconz/const.py
@@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__package__)
DOMAIN = "deconz"
-HASSIO_CONFIGURATION_URL = "homeassistant://hassio/ingress/core_deconz"
+HASSIO_CONFIGURATION_URL = "homeassistant://app/core_deconz"
CONF_BRIDGE_ID = "bridgeid"
CONF_GROUP_ID_BASE = "group_id_base"
diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py
index 158ac391b9b..4bc723abfce 100644
--- a/homeassistant/components/deconz/device_trigger.py
+++ b/homeassistant/components/deconz/device_trigger.py
@@ -169,6 +169,7 @@ FRIENDS_OF_HUE_SWITCH = {
}
RODRET_REMOTE_MODEL = "RODRET Dimmer"
+RODRET_REMOTE_MODEL_2 = "RODRET wireless dimmer"
RODRET_REMOTE = {
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001},
@@ -624,6 +625,7 @@ REMOTES = {
HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE,
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
RODRET_REMOTE_MODEL: RODRET_REMOTE,
+ RODRET_REMOTE_MODEL_2: RODRET_REMOTE,
SOMRIG_REMOTE_MODEL: SOMRIG_REMOTE,
STYRBAR_REMOTE_MODEL: STYRBAR_REMOTE,
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,
diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py
index 306ddc8e9a5..b4bd6ab1b92 100644
--- a/homeassistant/components/digital_ocean/__init__.py
+++ b/homeassistant/components/digital_ocean/__init__.py
@@ -1,6 +1,7 @@
"""Support for Digital Ocean."""
-from datetime import timedelta
+from __future__ import annotations
+
import logging
import digitalocean
@@ -12,27 +13,12 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
+from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES
+
_LOGGER = logging.getLogger(__name__)
-ATTR_CREATED_AT = "created_at"
-ATTR_DROPLET_ID = "droplet_id"
-ATTR_DROPLET_NAME = "droplet_name"
-ATTR_FEATURES = "features"
-ATTR_IPV4_ADDRESS = "ipv4_address"
-ATTR_IPV6_ADDRESS = "ipv6_address"
-ATTR_MEMORY = "memory"
-ATTR_REGION = "region"
-ATTR_VCPUS = "vcpus"
-ATTRIBUTION = "Data provided by Digital Ocean"
-
-CONF_DROPLETS = "droplets"
-
-DATA_DIGITAL_OCEAN = "data_do"
DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR]
-DOMAIN = "digital_ocean"
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py
index f0bb6eba049..6439a97ade8 100644
--- a/homeassistant/components/digital_ocean/binary_sensor.py
+++ b/homeassistant/components/digital_ocean/binary_sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import voluptuous as vol
@@ -16,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import (
+from .const import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -65,6 +66,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
"""Representation of a Digital Ocean droplet sensor."""
_attr_attribution = ATTRIBUTION
+ _attr_device_class = BinarySensorDeviceClass.MOVING
def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor."""
@@ -79,17 +81,12 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
return self.data.name
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.data.status == "active"
@property
- def device_class(self):
- """Return the class of this sensor."""
- return BinarySensorDeviceClass.MOVING
-
- @property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,
diff --git a/homeassistant/components/digital_ocean/const.py b/homeassistant/components/digital_ocean/const.py
new file mode 100644
index 00000000000..77dfb1bf4e2
--- /dev/null
+++ b/homeassistant/components/digital_ocean/const.py
@@ -0,0 +1,30 @@
+"""Support for Digital Ocean."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from . import DigitalOcean
+
+ATTR_CREATED_AT = "created_at"
+ATTR_DROPLET_ID = "droplet_id"
+ATTR_DROPLET_NAME = "droplet_name"
+ATTR_FEATURES = "features"
+ATTR_IPV4_ADDRESS = "ipv4_address"
+ATTR_IPV6_ADDRESS = "ipv6_address"
+ATTR_MEMORY = "memory"
+ATTR_REGION = "region"
+ATTR_VCPUS = "vcpus"
+
+ATTRIBUTION = "Data provided by Digital Ocean"
+
+CONF_DROPLETS = "droplets"
+
+DOMAIN = "digital_ocean"
+DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py
index 409fa63c1c2..a3e6b4f95bf 100644
--- a/homeassistant/components/digital_ocean/switch.py
+++ b/homeassistant/components/digital_ocean/switch.py
@@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import (
+from .const import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -80,12 +80,12 @@ class DigitalOceanSwitch(SwitchEntity):
return self.data.name
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if switch is on."""
return self.data.status == "active"
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,
diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json
index f014cbdbe88..d700c89ab6f 100644
--- a/homeassistant/components/dnsip/manifest.json
+++ b/homeassistant/components/dnsip/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["aiodns==3.6.1"]
+ "requirements": ["aiodns==4.0.0"]
}
diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py
index 0b74f97d06f..b074b4cc17c 100644
--- a/homeassistant/components/dovado/notify.py
+++ b/homeassistant/components/dovado/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.core import HomeAssistant
@@ -29,7 +30,7 @@ class DovadoSMSNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
- def send_message(self, message, **kwargs):
+ def send_message(self, message: str, **kwargs: Any) -> None:
"""Send SMS to the specified target phone number."""
if not (target := kwargs.get(ATTR_TARGET)):
_LOGGER.error("One target is required")
diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json
index 5cecb1d49f6..c987e75e718 100644
--- a/homeassistant/components/easyenergy/manifest.json
+++ b/homeassistant/components/easyenergy/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["easyenergy==2.1.2"],
+ "requirements": ["easyenergy==2.2.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py
index 5c36c311bff..a32993af094 100644
--- a/homeassistant/components/ebusd/__init__.py
+++ b/homeassistant/components/ebusd/__init__.py
@@ -1,6 +1,7 @@
"""Support for Ebusd daemon for communication with eBUS heating systems."""
import logging
+from typing import Any
import ebusdpy
import voluptuous as vol
@@ -17,7 +18,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN, SENSOR_TYPES
+from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
@@ -28,9 +29,9 @@ CACHE_TTL = 900
SERVICE_EBUSD_WRITE = "ebusd_write"
-def verify_ebusd_config(config):
+def verify_ebusd_config(config: ConfigType) -> ConfigType:
"""Verify eBusd config."""
- circuit = config[CONF_CIRCUIT]
+ circuit: str = config[CONF_CIRCUIT]
for condition in config[CONF_MONITORED_CONDITIONS]:
if condition not in SENSOR_TYPES[circuit]:
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
@@ -59,17 +60,17 @@ CONFIG_SCHEMA = vol.Schema(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the eBusd component."""
_LOGGER.debug("Integration setup started")
- conf = config[DOMAIN]
- name = conf[CONF_NAME]
- circuit = conf[CONF_CIRCUIT]
- monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
- server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
+ conf: ConfigType = config[DOMAIN]
+ name: str = conf[CONF_NAME]
+ circuit: str = conf[CONF_CIRCUIT]
+ monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
+ server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
try:
ebusdpy.init(server_address)
except (TimeoutError, OSError):
return False
- hass.data[DOMAIN] = EbusdData(server_address, circuit)
+ hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
sensor_config = {
CONF_MONITORED_CONDITIONS: monitored_conditions,
"client_name": name,
@@ -77,7 +78,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
- hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
+ hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
_LOGGER.debug("Ebusd integration setup completed")
return True
@@ -86,13 +87,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class EbusdData:
"""Get the latest data from Ebusd."""
- def __init__(self, address, circuit):
+ def __init__(self, address: tuple[str, int], circuit: str) -> None:
"""Initialize the data object."""
self._circuit = circuit
self._address = address
- self.value = {}
+ self.value: dict[str, Any] = {}
- def update(self, name, stype):
+ def update(self, name: str, stype: int) -> None:
"""Call the Ebusd API to update the data."""
try:
_LOGGER.debug("Opening socket to ebusd %s", name)
diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py
index 4fb3032e19b..10e46f6a2b9 100644
--- a/homeassistant/components/ebusd/const.py
+++ b/homeassistant/components/ebusd/const.py
@@ -1,5 +1,9 @@
"""Constants for ebus component."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
PERCENTAGE,
@@ -8,277 +12,283 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from . import EbusdData
DOMAIN = "ebusd"
+EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
-SENSOR_TYPES = {
+type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
+SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
"700": {
- "ActualFlowTemperatureDesired": [
+ "ActualFlowTemperatureDesired": (
"Hc1ActualFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "MaxFlowTemperatureDesired": [
+ ),
+ "MaxFlowTemperatureDesired": (
"Hc1MaxFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "MinFlowTemperatureDesired": [
+ ),
+ "MinFlowTemperatureDesired": (
"Hc1MinFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
- "HCSummerTemperatureLimit": [
+ ),
+ "PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
+ "HCSummerTemperatureLimit": (
"Hc1SummerTempLimit",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "HolidayTemperature": [
+ ),
+ "HolidayTemperature": (
"HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "HWTemperatureDesired": [
+ ),
+ "HWTemperatureDesired": (
"HwcTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "HWActualTemperature": [
+ ),
+ "HWActualTemperature": (
"HwcStorageTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
- "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
- "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
- "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
- "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
- "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
- "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
- "HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
- "WaterPressure": [
+ ),
+ "HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
+ "HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
+ "HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
+ "HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
+ "HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
+ "HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
+ "HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
+ "HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
+ "WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:water-pump",
0,
SensorDeviceClass.PRESSURE,
- ],
- "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
- "Zone1NightTemperature": [
+ ),
+ "Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
+ "Zone1NightTemperature": (
"z1NightTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-night",
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "Zone1DayTemperature": [
+ ),
+ "Zone1DayTemperature": (
"z1DayTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "Zone1HolidayTemperature": [
+ ),
+ "Zone1HolidayTemperature": (
"z1HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "Zone1RoomTemperature": [
+ ),
+ "Zone1RoomTemperature": (
"z1RoomTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "Zone1ActualRoomTemperatureDesired": [
+ ),
+ "Zone1ActualRoomTemperatureDesired": (
"z1ActualRoomTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
- "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
- "Zone1TimerWednesday": [
+ ),
+ "Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
+ "Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
+ "Zone1TimerWednesday": (
"z1Timer.Wednesday",
None,
"mdi:timer-outline",
1,
None,
- ],
- "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
- "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
- "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
- "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
- "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
- "ContinuosHeating": [
+ ),
+ "Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
+ "Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
+ "Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
+ "Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
+ "Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
+ "ContinuosHeating": (
"ContinuosHeating",
UnitOfTemperature.CELSIUS,
"mdi:weather-snowy",
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "PowerEnergyConsumptionLastMonth": [
+ ),
+ "PowerEnergyConsumptionLastMonth": (
"PrEnergySumHcLastMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
- ],
- "PowerEnergyConsumptionThisMonth": [
+ ),
+ "PowerEnergyConsumptionThisMonth": (
"PrEnergySumHcThisMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
- ],
+ ),
},
"ehp": {
- "HWTemperature": [
+ "HWTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "OutsideTemp": [
+ ),
+ "OutsideTemp": (
"OutsideTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
+ ),
},
"bai": {
- "HotWaterTemperature": [
+ "HotWaterTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "StorageTemperature": [
+ ),
+ "StorageTemperature": (
"StorageTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "DesiredStorageTemperature": [
+ ),
+ "DesiredStorageTemperature": (
"StorageTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "OutdoorsTemperature": [
+ ),
+ "OutdoorsTemperature": (
"OutdoorstempSensor",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "WaterPressure": [
+ ),
+ "WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:pipe",
4,
SensorDeviceClass.PRESSURE,
- ],
- "AverageIgnitionTime": [
+ ),
+ "AverageIgnitionTime": (
"averageIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
- ],
- "MaximumIgnitionTime": [
+ ),
+ "MaximumIgnitionTime": (
"maxIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
- ],
- "MinimumIgnitionTime": [
+ ),
+ "MinimumIgnitionTime": (
"minIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
- ],
- "ReturnTemperature": [
+ ),
+ "ReturnTemperature": (
"ReturnTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
- "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
- "DesiredFlowTemperature": [
+ ),
+ "CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
+ "HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
+ "DesiredFlowTemperature": (
"FlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
- ],
- "FlowTemperature": [
+ ),
+ "FlowTemperature": (
"FlowTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
- ],
- "Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
- "PowerEnergyConsumptionHeatingCircuit": [
+ ),
+ "Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
+ "PowerEnergyConsumptionHeatingCircuit": (
"PrEnergySumHc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
- ],
- "PowerEnergyConsumptionHotWaterCircuit": [
+ ),
+ "PowerEnergyConsumptionHotWaterCircuit": (
"PrEnergySumHwc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
- ],
- "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
- "HeatingPartLoad": [
+ ),
+ "RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
+ "HeatingPartLoad": (
"PartloadHcKW",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
- ],
- "StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
- "ModulationPercentage": [
+ ),
+ "StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
+ "ModulationPercentage": (
"ModulationTempDesired",
PERCENTAGE,
"mdi:percent",
0,
None,
- ],
+ ),
},
}
diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py
index ccd04be585e..a69a0343220 100644
--- a/homeassistant/components/ebusd/sensor.py
+++ b/homeassistant/components/ebusd/sensor.py
@@ -4,14 +4,16 @@ from __future__ import annotations
import datetime
import logging
+from typing import Any, cast
-from homeassistant.components.sensor import SensorEntity
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
-from .const import DOMAIN
+from . import EbusdData
+from .const import EBUSD_DATA, SensorSpecs
TIME_FRAME1_BEGIN = "time_frame1_begin"
TIME_FRAME1_END = "time_frame1_end"
@@ -33,9 +35,9 @@ def setup_platform(
"""Set up the Ebus sensor."""
if not discovery_info:
return
- ebusd_api = hass.data[DOMAIN]
- monitored_conditions = discovery_info["monitored_conditions"]
- name = discovery_info["client_name"]
+ ebusd_api = hass.data[EBUSD_DATA]
+ monitored_conditions: list[str] = discovery_info["monitored_conditions"]
+ name: str = discovery_info["client_name"]
add_entities(
(
@@ -49,9 +51,8 @@ def setup_platform(
class EbusdSensor(SensorEntity):
"""Ebusd component sensor methods definition."""
- def __init__(self, data, sensor, name):
+ def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
"""Initialize the sensor."""
- self._state = None
self._client_name = name
(
self._name,
@@ -63,20 +64,15 @@ class EbusdSensor(SensorEntity):
self.data = data
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._client_name} {self._name}"
@property
- def native_value(self):
- """Return the state of the sensor."""
- return self._state
-
- @property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
- if self._type == 1 and self._state is not None:
- schedule = {
+ if self._type == 1 and (native_value := self.native_value) is not None:
+ schedule: dict[str, str | None] = {
TIME_FRAME1_BEGIN: None,
TIME_FRAME1_END: None,
TIME_FRAME2_BEGIN: None,
@@ -84,7 +80,7 @@ class EbusdSensor(SensorEntity):
TIME_FRAME3_BEGIN: None,
TIME_FRAME3_END: None,
}
- time_frame = self._state.split(";")
+ time_frame = cast(str, native_value).split(";")
for index, item in enumerate(sorted(schedule.items())):
if index < len(time_frame):
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
@@ -96,17 +92,17 @@ class EbusdSensor(SensorEntity):
return None
@property
- def device_class(self):
+ def device_class(self) -> SensorDeviceClass | None:
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._device_class
@property
- def icon(self):
+ def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
return self._icon
@property
- def native_unit_of_measurement(self):
+ def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
return self._unit_of_measurement
@@ -118,6 +114,6 @@ class EbusdSensor(SensorEntity):
if self._name not in self.data.value:
return
- self._state = self.data.value[self._name]
+ self._attr_native_value = self.data.value[self._name]
except RuntimeError:
_LOGGER.debug("EbusdData.update exception")
diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py
index eb6b4cd49d8..dc94bb79a4e 100644
--- a/homeassistant/components/egardia/__init__.py
+++ b/homeassistant/components/egardia/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
@@ -35,7 +36,7 @@ DEFAULT_REPORT_SERVER_PORT = 52010
DEFAULT_VERSION = "GATE-01"
DOMAIN = "egardia"
-EGARDIA_DEVICE = "egardiadevice"
+EGARDIA_DEVICE: HassKey[egardiadevice.EgardiaDevice] = HassKey(DOMAIN)
EGARDIA_NAME = "egardianame"
EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes"
EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled"
diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py
index 5a18a23541a..9ebe8c1704e 100644
--- a/homeassistant/components/egardia/alarm_control_panel.py
+++ b/homeassistant/components/egardia/alarm_control_panel.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
+from pythonegardia.egardiadevice import EgardiaDevice
import requests
from homeassistant.components.alarm_control_panel import (
@@ -11,6 +12,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
+from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -47,10 +49,10 @@ def setup_platform(
if discovery_info is None:
return
device = EgardiaAlarm(
- discovery_info["name"],
+ discovery_info[CONF_NAME],
hass.data[EGARDIA_DEVICE],
discovery_info[CONF_REPORT_SERVER_ENABLED],
- discovery_info.get(CONF_REPORT_SERVER_CODES),
+ discovery_info[CONF_REPORT_SERVER_CODES],
discovery_info[CONF_REPORT_SERVER_PORT],
)
@@ -67,8 +69,13 @@ class EgardiaAlarm(AlarmControlPanelEntity):
)
def __init__(
- self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010
- ):
+ self,
+ name: str,
+ egardiasystem: EgardiaDevice,
+ rs_enabled: bool,
+ rs_codes: dict[str, list[str]],
+ rs_port: int,
+ ) -> None:
"""Initialize the Egardia alarm."""
self._attr_name = name
self._egardiasystem = egardiasystem
@@ -85,9 +92,7 @@ class EgardiaAlarm(AlarmControlPanelEntity):
@property
def should_poll(self) -> bool:
"""Poll if no report server is enabled."""
- if not self._rs_enabled:
- return True
- return False
+ return not self._rs_enabled
def handle_status_event(self, event):
"""Handle the Egardia system status event."""
diff --git a/homeassistant/components/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py
index 53505f58d3b..9c778cdad5a 100644
--- a/homeassistant/components/egardia/binary_sensor.py
+++ b/homeassistant/components/egardia/binary_sensor.py
@@ -2,11 +2,12 @@
from __future__ import annotations
+from pythonegardia.egardiadevice import EgardiaDevice
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -51,30 +52,20 @@ async def async_setup_platform(
class EgardiaBinarySensor(BinarySensorEntity):
"""Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
- def __init__(self, sensor_id, name, egardia_system, device_class):
+ def __init__(
+ self,
+ sensor_id: str,
+ name: str,
+ egardia_system: EgardiaDevice,
+ device_class: BinarySensorDeviceClass | None,
+ ) -> None:
"""Initialize the sensor device."""
self._id = sensor_id
- self._name = name
- self._state = None
- self._device_class = device_class
+ self._attr_name = name
+ self._attr_device_class = device_class
self._egardia_system = egardia_system
def update(self) -> None:
"""Update the status."""
egardia_input = self._egardia_system.getsensorstate(self._id)
- self._state = STATE_ON if egardia_input else STATE_OFF
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def is_on(self):
- """Whether the device is switched on."""
- return self._state == STATE_ON
-
- @property
- def device_class(self):
- """Return the device class."""
- return self._device_class
+ self._attr_is_on = bool(egardia_input)
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index bebbbe004e9..abc8d4cdad2 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
- "requirements": ["pyenphase==2.4.2"],
+ "requirements": ["pyenphase==2.4.3"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py
index 919704a6728..ee5468ddd81 100644
--- a/homeassistant/components/envisalink/__init__.py
+++ b/homeassistant/components/envisalink/__init__.py
@@ -18,12 +18,13 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
DOMAIN = "envisalink"
-DATA_EVL = "envisalink"
+DATA_EVL: HassKey[EnvisalinkAlarmPanel] = HassKey(DOMAIN)
CONF_EVL_KEEPALIVE = "keepalive_interval"
CONF_EVL_PORT = "port"
diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py
index 9d1b6d0d7a1..c1cee5198f2 100644
--- a/homeassistant/components/envisalink/alarm_control_panel.py
+++ b/homeassistant/components/envisalink/alarm_control_panel.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import logging
+from typing import Any
+from pyenvisalink import EnvisalinkAlarmPanel
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
@@ -22,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PANIC,
CONF_PARTITIONNAME,
+ CONF_PARTITIONS,
DATA_EVL,
DOMAIN,
PARTITION_SCHEMA,
@@ -51,15 +54,14 @@ async def async_setup_platform(
"""Perform the setup for Envisalink alarm panels."""
if not discovery_info:
return
- configured_partitions = discovery_info["partitions"]
- code = discovery_info[CONF_CODE]
- panic_type = discovery_info[CONF_PANIC]
+ configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
+ code: str | None = discovery_info[CONF_CODE]
+ panic_type: str = discovery_info[CONF_PANIC]
entities = []
- for part_num in configured_partitions:
- entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
+ for part_num, part_config in configured_partitions.items():
+ entity_config_data = PARTITION_SCHEMA(part_config)
entity = EnvisalinkAlarm(
- hass,
part_num,
entity_config_data[CONF_PARTITIONNAME],
code,
@@ -103,8 +105,14 @@ class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity):
)
def __init__(
- self, hass, partition_number, alarm_name, code, panic_type, info, controller
- ):
+ self,
+ partition_number: int,
+ alarm_name: str,
+ code: str | None,
+ panic_type: str,
+ info: dict[str, Any],
+ controller: EnvisalinkAlarmPanel,
+ ) -> None:
"""Initialize the alarm panel."""
self._partition_number = partition_number
self._panic_type = panic_type
diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py
index 6c4e2b528e9..aa91731216f 100644
--- a/homeassistant/components/envisalink/binary_sensor.py
+++ b/homeassistant/components/envisalink/binary_sensor.py
@@ -4,8 +4,14 @@ from __future__ import annotations
import datetime
import logging
+from typing import Any
-from homeassistant.components.binary_sensor import BinarySensorEntity
+from pyenvisalink import EnvisalinkAlarmPanel
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+)
from homeassistant.const import ATTR_LAST_TRIP_TIME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -13,7 +19,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
-from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA
+from . import (
+ CONF_ZONENAME,
+ CONF_ZONES,
+ CONF_ZONETYPE,
+ DATA_EVL,
+ SIGNAL_ZONE_UPDATE,
+ ZONE_SCHEMA,
+)
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -28,13 +41,12 @@ async def async_setup_platform(
"""Set up the Envisalink binary sensor entities."""
if not discovery_info:
return
- configured_zones = discovery_info["zones"]
+ configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
entities = []
- for zone_num in configured_zones:
- entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
+ for zone_num, zone_data in configured_zones.items():
+ entity_config_data = ZONE_SCHEMA(zone_data)
entity = EnvisalinkBinarySensor(
- hass,
zone_num,
entity_config_data[CONF_ZONENAME],
entity_config_data[CONF_ZONETYPE],
@@ -49,9 +61,16 @@ async def async_setup_platform(
class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Representation of an Envisalink binary sensor."""
- def __init__(self, hass, zone_number, zone_name, zone_type, info, controller):
+ def __init__(
+ self,
+ zone_number: int,
+ zone_name: str,
+ zone_type: BinarySensorDeviceClass,
+ info: dict[str, Any],
+ controller: EnvisalinkAlarmPanel,
+ ) -> None:
"""Initialize the binary_sensor."""
- self._zone_type = zone_type
+ self._attr_device_class = zone_type
self._zone_number = zone_number
_LOGGER.debug("Setting up zone: %s", zone_name)
@@ -66,9 +85,9 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
)
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
- attr = {}
+ attr: dict[str, Any] = {}
# The Envisalink library returns a "last_fault" value that's the
# number of seconds since the last fault, up to a maximum of 327680
@@ -101,11 +120,6 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Return true if sensor is on."""
return self._info["status"]["open"]
- @property
- def device_class(self):
- """Return the class of this sensor, from DEVICE_CLASSES."""
- return self._zone_type
-
@callback
def async_update_callback(self, zone):
"""Update the zone's state, if needed."""
diff --git a/homeassistant/components/envisalink/entity.py b/homeassistant/components/envisalink/entity.py
index a686ed2e3cb..6327ecee4e9 100644
--- a/homeassistant/components/envisalink/entity.py
+++ b/homeassistant/components/envisalink/entity.py
@@ -1,5 +1,9 @@
"""Support for Envisalink devices."""
+from typing import Any
+
+from pyenvisalink import EnvisalinkAlarmPanel
+
from homeassistant.helpers.entity import Entity
@@ -8,13 +12,10 @@ class EnvisalinkEntity(Entity):
_attr_should_poll = False
- def __init__(self, name, info, controller):
+ def __init__(
+ self, name: str, info: dict[str, Any], controller: EnvisalinkAlarmPanel
+ ) -> None:
"""Initialize the device."""
self._controller = controller
self._info = info
- self._name = name
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
+ self._attr_name = name
diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py
index 70d471a685c..d9b9ccab164 100644
--- a/homeassistant/components/envisalink/sensor.py
+++ b/homeassistant/components/envisalink/sensor.py
@@ -3,6 +3,9 @@
from __future__ import annotations
import logging
+from typing import Any
+
+from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, callback
@@ -12,6 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PARTITIONNAME,
+ CONF_PARTITIONS,
DATA_EVL,
PARTITION_SCHEMA,
SIGNAL_KEYPAD_UPDATE,
@@ -31,13 +35,12 @@ async def async_setup_platform(
"""Perform the setup for Envisalink sensor entities."""
if not discovery_info:
return
- configured_partitions = discovery_info["partitions"]
+ configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
entities = []
- for part_num in configured_partitions:
- entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
+ for part_num, part_config in configured_partitions.items():
+ entity_config_data = PARTITION_SCHEMA(part_config)
entity = EnvisalinkSensor(
- hass,
entity_config_data[CONF_PARTITIONNAME],
part_num,
hass.data[DATA_EVL].alarm_state["partition"][part_num],
@@ -52,9 +55,16 @@ async def async_setup_platform(
class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
"""Representation of an Envisalink keypad."""
- def __init__(self, hass, partition_name, partition_number, info, controller):
+ _attr_icon = "mdi:alarm"
+
+ def __init__(
+ self,
+ partition_name: str,
+ partition_number: int,
+ info: dict[str, Any],
+ controller: EnvisalinkAlarmPanel,
+ ) -> None:
"""Initialize the sensor."""
- self._icon = "mdi:alarm"
self._partition_number = partition_number
_LOGGER.debug("Setting up sensor for partition: %s", partition_name)
@@ -73,11 +83,6 @@ class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
)
)
- @property
- def icon(self):
- """Return the icon if any."""
- return self._icon
-
@property
def native_value(self):
"""Return the overall state."""
diff --git a/homeassistant/components/envisalink/switch.py b/homeassistant/components/envisalink/switch.py
index e4f37bf328d..81ecf8d8789 100644
--- a/homeassistant/components/envisalink/switch.py
+++ b/homeassistant/components/envisalink/switch.py
@@ -5,13 +5,21 @@ from __future__ import annotations
import logging
from typing import Any
+from pyenvisalink import EnvisalinkAlarmPanel
+
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA
+from . import (
+ CONF_ZONENAME,
+ CONF_ZONES,
+ DATA_EVL,
+ SIGNAL_ZONE_BYPASS_UPDATE,
+ ZONE_SCHEMA,
+)
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -26,16 +34,15 @@ async def async_setup_platform(
"""Set up the Envisalink switch entities."""
if not discovery_info:
return
- configured_zones = discovery_info["zones"]
+ configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
entities = []
- for zone_num in configured_zones:
- entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
+ for zone_num, zone_data in configured_zones.items():
+ entity_config_data = ZONE_SCHEMA(zone_data)
zone_name = f"{entity_config_data[CONF_ZONENAME]}_bypass"
_LOGGER.debug("Setting up zone_bypass switch: %s", zone_name)
entity = EnvisalinkSwitch(
- hass,
zone_num,
zone_name,
hass.data[DATA_EVL].alarm_state["zone"][zone_num],
@@ -49,7 +56,13 @@ async def async_setup_platform(
class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
"""Representation of an Envisalink switch."""
- def __init__(self, hass, zone_number, zone_name, info, controller):
+ def __init__(
+ self,
+ zone_number: int,
+ zone_name: str,
+ info: dict[str, Any],
+ controller: EnvisalinkAlarmPanel,
+ ) -> None:
"""Initialize the switch."""
self._zone_number = zone_number
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index f0d1123cdcd..87b7ec3361e 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -1034,7 +1034,7 @@ def _async_setup_device_registry(
and dashboard.data
and dashboard.data.get(device_info.name)
):
- configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
+ configuration_url = f"homeassistant://app/{dashboard.addon_slug}"
manufacturer = "espressif"
if device_info.manufacturer:
diff --git a/homeassistant/components/essent/const.py b/homeassistant/components/essent/const.py
index 6b4167925c0..4b505e21136 100644
--- a/homeassistant/components/essent/const.py
+++ b/homeassistant/components/essent/const.py
@@ -7,7 +7,7 @@ from enum import StrEnum
from typing import Final
DOMAIN: Final = "essent"
-UPDATE_INTERVAL: Final = timedelta(hours=12)
+UPDATE_INTERVAL: Final = timedelta(hours=1)
ATTRIBUTION: Final = "Data provided by Essent"
diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py
index edd46d24982..674da78ead2 100644
--- a/homeassistant/components/facebook/notify.py
+++ b/homeassistant/components/facebook/notify.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
+from typing import Any
import requests
import voluptuous as vol
@@ -46,7 +47,7 @@ class FacebookNotificationService(BaseNotificationService):
"""Initialize the service."""
self.page_access_token = access_token
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send some message."""
payload = {"access_token": self.page_access_token}
targets = kwargs.get(ATTR_TARGET)
diff --git a/homeassistant/components/fan/condition.py b/homeassistant/components/fan/condition.py
new file mode 100644
index 00000000000..2063e98033e
--- /dev/null
+++ b/homeassistant/components/fan/condition.py
@@ -0,0 +1,17 @@
+"""Provides conditions for fans."""
+
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.condition import Condition, make_entity_state_condition
+
+from . import DOMAIN
+
+CONDITIONS: dict[str, type[Condition]] = {
+ "is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
+ "is_on": make_entity_state_condition(DOMAIN, STATE_ON),
+}
+
+
+async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
+ """Return the fan conditions."""
+ return CONDITIONS
diff --git a/homeassistant/components/fan/conditions.yaml b/homeassistant/components/fan/conditions.yaml
new file mode 100644
index 00000000000..2f7e4fca5b9
--- /dev/null
+++ b/homeassistant/components/fan/conditions.yaml
@@ -0,0 +1,17 @@
+.condition_common: &condition_common
+ target:
+ entity:
+ domain: fan
+ fields:
+ behavior:
+ required: true
+ default: any
+ selector:
+ select:
+ translation_key: condition_behavior
+ options:
+ - all
+ - any
+
+is_off: *condition_common
+is_on: *condition_common
diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json
index 9f52b55bf7d..91a1924056f 100644
--- a/homeassistant/components/fan/icons.json
+++ b/homeassistant/components/fan/icons.json
@@ -1,4 +1,12 @@
{
+ "conditions": {
+ "is_off": {
+ "condition": "mdi:fan-off"
+ },
+ "is_on": {
+ "condition": "mdi:fan"
+ }
+ },
"entity_component": {
"_": {
"default": "mdi:fan",
diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json
index a6e4b91c65e..ba6df0a288f 100644
--- a/homeassistant/components/fan/strings.json
+++ b/homeassistant/components/fan/strings.json
@@ -1,8 +1,32 @@
{
"common": {
+ "condition_behavior_description": "How the state should match on the targeted fans.",
+ "condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted fans to trigger on.",
"trigger_behavior_name": "Behavior"
},
+ "conditions": {
+ "is_off": {
+ "description": "Tests if one or more fans are off.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::fan::common::condition_behavior_description%]",
+ "name": "[%key:component::fan::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a fan is off"
+ },
+ "is_on": {
+ "description": "Tests if one or more fans are on.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::fan::common::condition_behavior_description%]",
+ "name": "[%key:component::fan::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a fan is on"
+ }
+ },
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -65,6 +89,12 @@
}
},
"selector": {
+ "condition_behavior": {
+ "options": {
+ "all": "All",
+ "any": "Any"
+ }
+ },
"direction": {
"options": {
"forward": "Forward",
diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py
index 3ff2e326ebf..b5250d584d5 100644
--- a/homeassistant/components/firefly_iii/coordinator.py
+++ b/homeassistant/components/firefly_iii/coordinator.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
@@ -97,17 +98,30 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
end_date = now
try:
- accounts = await self.firefly.get_accounts()
- categories = await self.firefly.get_categories()
- category_details = [
- await self.firefly.get_category(
- category_id=int(category.id), start=start_date, end=end_date
+ (
+ accounts,
+ categories,
+ primary_currency,
+ budgets,
+ bills,
+ ) = await asyncio.gather(
+ self.firefly.get_accounts(),
+ self.firefly.get_categories(),
+ self.firefly.get_currency_primary(),
+ self.firefly.get_budgets(start=start_date, end=end_date),
+ self.firefly.get_bills(),
+ )
+
+ category_details = await asyncio.gather(
+ *(
+ self.firefly.get_category(
+ category_id=int(category.id),
+ start=start_date,
+ end=end_date,
+ )
+ for category in categories
)
- for category in categories
- ]
- primary_currency = await self.firefly.get_currency_primary()
- budgets = await self.firefly.get_budgets(start=start_date, end=end_date)
- bills = await self.firefly.get_bills()
+ )
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/firefly_iii/manifest.json b/homeassistant/components/firefly_iii/manifest.json
index 81330d45215..7af55ca1fc4 100644
--- a/homeassistant/components/firefly_iii/manifest.json
+++ b/homeassistant/components/firefly_iii/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["pyfirefly==0.1.10"]
+ "requirements": ["pyfirefly==0.1.12"]
}
diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
index 9cee1d4f952..d8025225df5 100644
--- a/homeassistant/components/fitbit/sensor.py
+++ b/homeassistant/components/fitbit/sensor.py
@@ -461,7 +461,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
key="sleep/timeInBed",
translation_key="sleep_time_in_bed",
native_unit_of_measurement=UnitOfTime.MINUTES,
- icon="mdi:hotel",
+ icon="mdi:bed",
device_class=SensorDeviceClass.DURATION,
scope=FitbitScope.SLEEP,
state_class=SensorStateClass.TOTAL_INCREASING,
diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py
index f50e04cba36..d4e8f864ee8 100644
--- a/homeassistant/components/flock/notify.py
+++ b/homeassistant/components/flock/notify.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
import logging
+from typing import Any
import voluptuous as vol
@@ -47,7 +48,7 @@ class FlockNotificationService(BaseNotificationService):
self._url = url
self._session = session
- async def async_send_message(self, message, **kwargs):
+ async def async_send_message(self, message: str, **kwargs: Any) -> None:
"""Send the message to the user."""
payload = {"text": message}
diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py
index c7e3071c771..8f6613c5c23 100644
--- a/homeassistant/components/free_mobile/notify.py
+++ b/homeassistant/components/free_mobile/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
+from typing import Any
from freesms import FreeClient
import voluptuous as vol
@@ -40,7 +41,7 @@ class FreeSMSNotificationService(BaseNotificationService):
"""Initialize the service."""
self.free_client = FreeClient(username, access_token)
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the Free Mobile user cell."""
resp = self.free_client.send_sms(message)
diff --git a/homeassistant/components/fressnapf_tracker/config_flow.py b/homeassistant/components/fressnapf_tracker/config_flow.py
index 3823246308e..50531fe4624 100644
--- a/homeassistant/components/fressnapf_tracker/config_flow.py
+++ b/homeassistant/components/fressnapf_tracker/config_flow.py
@@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
- vol.Required(CONF_SMS_CODE): int,
+ vol.Required(CONF_SMS_CODE): str,
}
)
@@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
return errors, False
async def _async_verify_sms_code(
- self, sms_code: int
+ self, sms_code: str
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}
diff --git a/homeassistant/components/fressnapf_tracker/manifest.json b/homeassistant/components/fressnapf_tracker/manifest.json
index 4e493db07e9..482db2aedb0 100644
--- a/homeassistant/components/fressnapf_tracker/manifest.json
+++ b/homeassistant/components/fressnapf_tracker/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["fressnapftracker==0.2.0"]
+ "requirements": ["fressnapftracker==0.2.1"]
}
diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py
index 7fd158f3224..af5c1b0e869 100644
--- a/homeassistant/components/fritz/button.py
+++ b/homeassistant/components/fritz/button.py
@@ -164,13 +164,12 @@ def _async_wol_buttons_list(
class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity):
"""Defines a FRITZ!Box Tools Wake On LAN button."""
- _attr_icon = "mdi:lan-pending"
_attr_entity_registry_enabled_default = False
+ _attr_translation_key = "wake_on_lan"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize Fritz!Box WOL button."""
super().__init__(avm_wrapper, device)
- self._name = f"{self.hostname} Wake on LAN"
self._attr_unique_id = f"{self._mac}_wake_on_lan"
self._is_available = True
diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py
index a658f5d19cb..aa3dcf1370a 100644
--- a/homeassistant/components/fritz/device_tracker.py
+++ b/homeassistant/components/fritz/device_tracker.py
@@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import DEFAULT_DEVICE_NAME
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import device_filter_out_from_trackers
@@ -71,6 +72,7 @@ class FritzBoxTracker(FritzDeviceBase, ScannerEntity):
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper, device)
+ self._attr_name: str = device.hostname or DEFAULT_DEVICE_NAME
self._last_activity: datetime.datetime | None = device.last_activity
@property
diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py
index eb3d5b600dd..ad2a4d831d0 100644
--- a/homeassistant/components/fritz/entity.py
+++ b/homeassistant/components/fritz/entity.py
@@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DEFAULT_DEVICE_NAME, DOMAIN
+from .const import DOMAIN
from .coordinator import AvmWrapper
from .models import FritzDevice
@@ -21,21 +21,17 @@ from .models import FritzDevice
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
"""Entity base class for a device connected to a FRITZ!Box device."""
+ _attr_has_entity_name = True
+
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper)
self._avm_wrapper = avm_wrapper
self._mac: str = device.mac_address
- self._name: str = device.hostname or DEFAULT_DEVICE_NAME
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}
)
- @property
- def name(self) -> str:
- """Return device name."""
- return self._name
-
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
diff --git a/homeassistant/components/fritz/icons.json b/homeassistant/components/fritz/icons.json
index c20a8518b10..837c9b51095 100644
--- a/homeassistant/components/fritz/icons.json
+++ b/homeassistant/components/fritz/icons.json
@@ -3,6 +3,9 @@
"button": {
"cleanup": {
"default": "mdi:broom"
+ },
+ "wake_on_lan": {
+ "default": "mdi:lan-pending"
}
},
"sensor": {
@@ -48,6 +51,11 @@
"max_kb_s_sent": {
"default": "mdi:upload"
}
+ },
+ "switch": {
+ "internet_access": {
+ "default": "mdi:router-wireless-settings"
+ }
}
},
"services": {
diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json
index 45d9c6a0d5f..183bb931a14 100644
--- a/homeassistant/components/fritz/manifest.json
+++ b/homeassistant/components/fritz/manifest.json
@@ -8,6 +8,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
+ "quality_scale": "bronze",
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==1.0.2"],
"ssdp": [
{
diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml
index c2d18a0be84..f1893ef317f 100644
--- a/homeassistant/components/fritz/quality_scale.yaml
+++ b/homeassistant/components/fritz/quality_scale.yaml
@@ -13,9 +13,7 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
- has-entity-name:
- status: todo
- comment: partially done
+ has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index baff078bc41..c2aa92818b1 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -108,6 +108,9 @@
},
"reconnect": {
"name": "Reconnect"
+ },
+ "wake_on_lan": {
+ "name": "Wake on LAN"
}
},
"sensor": {
@@ -162,6 +165,11 @@
"max_kb_s_sent": {
"name": "Max connection upload throughput"
}
+ },
+ "switch": {
+ "internet_access": {
+ "name": "Internet access"
+ }
}
},
"exceptions": {
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index 9c143ad9471..45afbb45eb8 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -499,13 +499,12 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
"""Defines a FRITZ!Box Tools DeviceProfile switch."""
- _attr_icon = "mdi:router-wireless-settings"
+ _attr_translation_key = "internet_access"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Init Fritz profile."""
super().__init__(avm_wrapper, device)
self._attr_is_on: bool = False
- self._name = f"{device.hostname} Internet Access"
self._attr_unique_id = f"{self._mac}_internet_access"
self._attr_entity_category = EntityCategory.CONFIG
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 17140d49ac9..cbbf414fbb3 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20260107.0"]
+ "requirements": ["home-assistant-frontend==20260107.2"]
}
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index 76fcc4acdde..26a368bcd66 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -66,6 +66,7 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
+ CONF_KEEP_ALIVE,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -81,7 +82,6 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Generic Thermostat"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
-CONF_KEEP_ALIVE = "keep_alive"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"
diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py
index c1045cad536..88a09013d75 100644
--- a/homeassistant/components/generic_thermostat/config_flow.py
+++ b/homeassistant/components/generic_thermostat/config_flow.py
@@ -21,6 +21,7 @@ from .const import (
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
+ CONF_KEEP_ALIVE,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -59,6 +60,9 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_MIN_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
+ vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
+ selector.DurationSelectorConfig(allow_negative=False)
+ ),
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
diff --git a/homeassistant/components/generic_thermostat/const.py b/homeassistant/components/generic_thermostat/const.py
index f0e6f1a7d73..d4c25f698d2 100644
--- a/homeassistant/components/generic_thermostat/const.py
+++ b/homeassistant/components/generic_thermostat/const.py
@@ -33,4 +33,5 @@ CONF_PRESETS = {
)
}
CONF_SENSOR = "target_sensor"
+CONF_KEEP_ALIVE = "keep_alive"
DEFAULT_TOLERANCE = 0.3
diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json
index 6c8876d28eb..5257be051a2 100644
--- a/homeassistant/components/generic_thermostat/strings.json
+++ b/homeassistant/components/generic_thermostat/strings.json
@@ -18,6 +18,7 @@
"cold_tolerance": "Cold tolerance",
"heater": "Actuator switch",
"hot_tolerance": "Hot tolerance",
+ "keep_alive": "Keep-alive interval",
"max_temp": "Maximum target temperature",
"min_cycle_duration": "Minimum cycle duration",
"min_temp": "Minimum target temperature",
@@ -29,6 +30,7 @@
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
+ "keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
@@ -45,6 +47,7 @@
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
+ "keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
@@ -55,6 +58,7 @@
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
+ "keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}
diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py
index 890ca1578be..874bd0cee7b 100644
--- a/homeassistant/components/geniushub/switch.py
+++ b/homeassistant/components/geniushub/switch.py
@@ -57,7 +57,7 @@ class GeniusSwitch(GeniusZone, SwitchEntity):
"""Representation of a Genius Hub switch."""
@property
- def device_class(self):
+ def device_class(self) -> SwitchDeviceClass:
"""Return the class of this device, from component DEVICE_CLASSES."""
return SwitchDeviceClass.OUTLET
diff --git a/homeassistant/components/google_air_quality/icons.json b/homeassistant/components/google_air_quality/icons.json
index 197c201d8ee..b3f3370b0b8 100644
--- a/homeassistant/components/google_air_quality/icons.json
+++ b/homeassistant/components/google_air_quality/icons.json
@@ -1,14 +1,20 @@
{
"entity": {
"sensor": {
- "nitrogen_dioxide": {
+ "ammonia": {
+ "default": "mdi:molecule"
+ },
+ "benzene": {
+ "default": "mdi:molecule"
+ },
+ "nitrogen_monoxide": {
+ "default": "mdi:molecule"
+ },
+ "non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
- },
- "sulphur_dioxide": {
- "default": "mdi:molecule"
}
}
}
diff --git a/homeassistant/components/google_air_quality/manifest.json b/homeassistant/components/google_air_quality/manifest.json
index 05b7fe1ae64..7848990961d 100644
--- a/homeassistant/components/google_air_quality/manifest.json
+++ b/homeassistant/components/google_air_quality/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
- "requirements": ["google_air_quality_api==2.1.2"]
+ "requirements": ["google_air_quality_api==3.0.0"]
}
diff --git a/homeassistant/components/google_air_quality/sensor.py b/homeassistant/components/google_air_quality/sensor.py
index c48d6771976..ac384aa3ff5 100644
--- a/homeassistant/components/google_air_quality/sensor.py
+++ b/homeassistant/components/google_air_quality/sensor.py
@@ -13,7 +13,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigSubentry
-from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -99,18 +103,53 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
"local_aqi": data.indexes[1].display_name
},
),
+ AirQualitySensorEntityDescription(
+ key="c6h6",
+ translation_key="benzene",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement_fn=lambda x: x.pollutants.c6h6.concentration.units,
+ value_fn=lambda x: x.pollutants.c6h6.concentration.value,
+ exists_fn=lambda x: "c6h6" in {p.code for p in x.pollutants},
+ ),
AirQualitySensorEntityDescription(
key="co",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CO,
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
+ exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.co.concentration.value,
+ suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ ),
+ AirQualitySensorEntityDescription(
+ key="nh3",
+ translation_key="ammonia",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement_fn=lambda x: x.pollutants.nh3.concentration.units,
+ value_fn=lambda x: x.pollutants.nh3.concentration.value,
+ exists_fn=lambda x: "nh3" in {p.code for p in x.pollutants},
+ ),
+ AirQualitySensorEntityDescription(
+ key="nmhc",
+ translation_key="non_methane_hydrocarbons",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement_fn=lambda x: x.pollutants.nmhc.concentration.units,
+ value_fn=lambda x: x.pollutants.nmhc.concentration.value,
+ exists_fn=lambda x: "nmhc" in {p.code for p in x.pollutants},
+ ),
+ AirQualitySensorEntityDescription(
+ key="no",
+ translation_key="nitrogen_monoxide",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement_fn=lambda x: x.pollutants.no.concentration.units,
+ value_fn=lambda x: x.pollutants.no.concentration.value,
+ exists_fn=lambda x: "no" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="no2",
- translation_key="nitrogen_dioxide",
state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
+ exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.no2.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -118,6 +157,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
translation_key="ozone",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
+ exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.o3.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -125,6 +165,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement_fn=lambda x: x.pollutants.pm10.concentration.units,
+ exists_fn=lambda x: "pm10" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.pm10.concentration.value,
),
AirQualitySensorEntityDescription(
@@ -132,13 +173,15 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement_fn=lambda x: x.pollutants.pm25.concentration.units,
+ exists_fn=lambda x: "pm25" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.pm25.concentration.value,
),
AirQualitySensorEntityDescription(
key="so2",
- translation_key="sulphur_dioxide",
state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
+ exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.so2.concentration.value,
),
)
diff --git a/homeassistant/components/google_air_quality/strings.json b/homeassistant/components/google_air_quality/strings.json
index 8c2a7e75207..8ca0dfcef98 100644
--- a/homeassistant/components/google_air_quality/strings.json
+++ b/homeassistant/components/google_air_quality/strings.json
@@ -76,6 +76,12 @@
},
"entity": {
"sensor": {
+ "ammonia": {
+ "name": "Ammonia"
+ },
+ "benzene": {
+ "name": "Benzene"
+ },
"local_aqi": {
"name": "{local_aqi} AQI"
},
@@ -189,6 +195,9 @@
"name": "{local_aqi} dominant pollutant",
"state": {
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
+ "nh3": "[%key:component::google_air_quality::entity::sensor::ammonia::name%]",
+ "nmhc": "[%key:component::google_air_quality::entity::sensor::non_methane_hydrocarbons::name%]",
+ "no": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
@@ -196,15 +205,15 @@
"so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
- "nitrogen_dioxide": {
- "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
+ "nitrogen_monoxide": {
+ "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
+ },
+ "non_methane_hydrocarbons": {
+ "name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},
- "sulphur_dioxide": {
- "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
- },
"uaqi": {
"name": "Universal Air Quality Index"
},
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index c23c68982a5..073215bfaf6 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["google-genai==1.56.0"]
+ "requirements": ["google-genai==1.59.0"]
}
diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json
index dba8cd6077c..e10b9141911 100644
--- a/homeassistant/components/gree/manifest.json
+++ b/homeassistant/components/gree/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
- "requirements": ["greeclimate==2.1.0"]
+ "requirements": ["greeclimate==2.1.1"]
}
diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py
index b67e10dd960..078b6a29739 100644
--- a/homeassistant/components/group/sensor.py
+++ b/homeassistant/components/group/sensor.py
@@ -346,7 +346,6 @@ class SensorGroup(GroupEntity, SensorEntity):
self._attr_name = name
if name == DEFAULT_NAME:
self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize()
- self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._ignore_non_numeric = ignore_non_numeric
self.mode = all if ignore_non_numeric is False else any
@@ -374,7 +373,7 @@ class SensorGroup(GroupEntity, SensorEntity):
def async_update_group_state(self) -> None:
"""Query all members and determine the sensor group state."""
self.calculate_state_attributes(self._get_valid_entities())
- states: list[str] = []
+ states: list[str | None] = []
valid_units = self._valid_units
valid_states: list[bool] = []
sensor_values: list[tuple[str, float, State]] = []
@@ -435,9 +434,12 @@ class SensorGroup(GroupEntity, SensorEntity):
state.attributes.get("unit_of_measurement"),
self.entity_id,
)
+ else:
+ states.append(None)
+ valid_states.append(False)
- # Set group as unavailable if all members do not have numeric values
- self._attr_available = any(numeric_state for numeric_state in valid_states)
+ # Set group as unavailable if all members are unavailable or missing
+ self._attr_available = not all(s in (STATE_UNAVAILABLE, None) for s in states)
valid_state = self.mode(
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
@@ -446,6 +448,7 @@ class SensorGroup(GroupEntity, SensorEntity):
if not valid_state or not valid_state_numeric:
self._attr_native_value = None
+ self._extra_state_attribute = {}
return
# Calculate values
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index 3896dcb8ff0..17aed3a1094 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -83,6 +83,9 @@
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"advanced": {
"data": {
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index a1f30276d1f..860f938ef35 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -16,7 +16,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
-from homeassistant.components import panel_custom
+from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -292,6 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
return False
async_load_websocket_api(hass)
+ frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py
index f0ccecb22f1..67796b52c5e 100644
--- a/homeassistant/components/hassio/addon_panel.py
+++ b/homeassistant/components/hassio/addon_panel.py
@@ -6,7 +6,7 @@ from typing import Any
from aiohttp import web
-from homeassistant.components import frontend, panel_custom
+from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
@@ -33,7 +33,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
# _register_panel never suspends and is only
# a coroutine because it would be a breaking change
# to make it a normal function
- await _register_panel(hass, addon, data)
+ _register_panel(hass, addon, data)
class HassIOAddonPanel(HomeAssistantView):
@@ -58,7 +58,7 @@ class HassIOAddonPanel(HomeAssistantView):
data = panels[addon]
# Register panel
- await _register_panel(self.hass, addon, data)
+ _register_panel(self.hass, addon, data)
return web.Response()
async def delete(self, request: web.Request, addon: str) -> web.Response:
@@ -76,18 +76,14 @@ class HassIOAddonPanel(HomeAssistantView):
return {}
-async def _register_panel(
- hass: HomeAssistant, addon: str, data: dict[str, Any]
-) -> None:
+def _register_panel(hass: HomeAssistant, addon: str, data: dict[str, Any]):
"""Init coroutine to register the panel."""
- await panel_custom.async_register_panel(
+ frontend.async_register_built_in_panel(
hass,
+ "app",
frontend_url_path=addon,
- webcomponent_name="hassio-main",
sidebar_title=data[ATTR_TITLE],
sidebar_icon=data[ATTR_ICON],
- js_url="/api/hassio/app/entrypoint.js",
- embed_iframe=True,
require_admin=data[ATTR_ADMIN],
- config={"ingress": addon},
+ config={"addon": addon},
)
diff --git a/homeassistant/components/hdfury/__init__.py b/homeassistant/components/hdfury/__init__.py
index 8eaf51a3aa0..fcf40cbbac0 100644
--- a/homeassistant/components/hdfury/__init__.py
+++ b/homeassistant/components/hdfury/__init__.py
@@ -8,6 +8,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.BUTTON,
Platform.SELECT,
+ Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/hdfury/button.py b/homeassistant/components/hdfury/button.py
index d56864c1f5e..6b2a292c210 100644
--- a/homeassistant/components/hdfury/button.py
+++ b/homeassistant/components/hdfury/button.py
@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HDFuryButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/hdfury/diagnostics.py b/homeassistant/components/hdfury/diagnostics.py
new file mode 100644
index 00000000000..c561a97d4ee
--- /dev/null
+++ b/homeassistant/components/hdfury/diagnostics.py
@@ -0,0 +1,21 @@
+"""Diagnostics for HDFury Integration."""
+
+from typing import Any
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .coordinator import HDFuryCoordinator
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator: HDFuryCoordinator = entry.runtime_data
+
+ return {
+ "board": coordinator.data.board,
+ "info": coordinator.data.info,
+ "config": coordinator.data.config,
+ }
diff --git a/homeassistant/components/hdfury/icons.json b/homeassistant/components/hdfury/icons.json
index 9088ee37eaa..3590f512418 100644
--- a/homeassistant/components/hdfury/icons.json
+++ b/homeassistant/components/hdfury/icons.json
@@ -16,6 +16,50 @@
"default": "mdi:hdmi-port"
}
},
+ "sensor": {
+ "aud0": {
+ "default": "mdi:audio-input-rca"
+ },
+ "aud1": {
+ "default": "mdi:audio-input-rca"
+ },
+ "audout": {
+ "default": "mdi:television-speaker"
+ },
+ "earcrx": {
+ "default": "mdi:audio-video"
+ },
+ "edida0": {
+ "default": "mdi:format-list-text"
+ },
+ "edida1": {
+ "default": "mdi:format-list-text"
+ },
+ "edida2": {
+ "default": "mdi:format-list-text"
+ },
+ "rx0": {
+ "default": "mdi:video-input-hdmi"
+ },
+ "rx1": {
+ "default": "mdi:video-input-hdmi"
+ },
+ "sink0": {
+ "default": "mdi:television"
+ },
+ "sink1": {
+ "default": "mdi:television"
+ },
+ "sink2": {
+ "default": "mdi:audio-video"
+ },
+ "tx0": {
+ "default": "mdi:cable-data"
+ },
+ "tx1": {
+ "default": "mdi:cable-data"
+ }
+ },
"switch": {
"autosw": {
"default": "mdi:import"
diff --git a/homeassistant/components/hdfury/manifest.json b/homeassistant/components/hdfury/manifest.json
index 93c09362f30..86e044708c9 100644
--- a/homeassistant/components/hdfury/manifest.json
+++ b/homeassistant/components/hdfury/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "bronze",
+ "quality_scale": "silver",
"requirements": ["hdfury==1.3.1"]
}
diff --git a/homeassistant/components/hdfury/quality_scale.yaml b/homeassistant/components/hdfury/quality_scale.yaml
index 614a3344f10..02cae0ebd0c 100644
--- a/homeassistant/components/hdfury/quality_scale.yaml
+++ b/homeassistant/components/hdfury/quality_scale.yaml
@@ -35,15 +35,15 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration has no authentication flow.
- test-coverage: todo
+ test-coverage: done
# Gold
devices: done
- diagnostics: todo
+ diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo
diff --git a/homeassistant/components/hdfury/select.py b/homeassistant/components/hdfury/select.py
index c0849dc5ca9..7866b07e0f4 100644
--- a/homeassistant/components/hdfury/select.py
+++ b/homeassistant/components/hdfury/select.py
@@ -20,6 +20,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
from .entity import HDFuryEntity
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HDFurySelectEntityDescription(SelectEntityDescription):
@@ -77,13 +79,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- entities: list[HDFuryEntity] = []
-
- for description in SELECT_PORTS:
- if description.key not in coordinator.data.info:
- continue
-
- entities.append(HDFurySelect(coordinator, description))
+ entities: list[HDFuryEntity] = [
+ HDFurySelect(coordinator, description)
+ for description in SELECT_PORTS
+ if description.key in coordinator.data.info
+ ]
# Add OPMODE select if present
if "opmode" in coordinator.data.info:
diff --git a/homeassistant/components/hdfury/sensor.py b/homeassistant/components/hdfury/sensor.py
new file mode 100644
index 00000000000..23538c5f0f4
--- /dev/null
+++ b/homeassistant/components/hdfury/sensor.py
@@ -0,0 +1,123 @@
+"""Sensor platform for HDFury Integration."""
+
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import HDFuryConfigEntry
+from .entity import HDFuryEntity
+
+PARALLEL_UPDATES = 0
+
+SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="RX0",
+ translation_key="rx0",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="RX1",
+ translation_key="rx1",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="TX0",
+ translation_key="tx0",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="TX1",
+ translation_key="tx1",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="AUD0",
+ translation_key="aud0",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="AUD1",
+ translation_key="aud1",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="AUDOUT",
+ translation_key="audout",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="EARCRX",
+ translation_key="earcrx",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="SINK0",
+ translation_key="sink0",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="SINK1",
+ translation_key="sink1",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="SINK2",
+ translation_key="sink2",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="EDIDA0",
+ translation_key="edida0",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="EDIDA1",
+ translation_key="edida1",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="EDIDA2",
+ translation_key="edida2",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HDFuryConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up sensors using the platform schema."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ HDFurySensor(coordinator, description)
+ for description in SENSORS
+ if description.key in coordinator.data.info
+ )
+
+
+class HDFurySensor(HDFuryEntity, SensorEntity):
+ """Base HDFury Sensor Class."""
+
+ entity_description: SensorEntityDescription
+
+ @property
+ def native_value(self) -> str:
+ """Set Sensor Value."""
+
+ return self.coordinator.data.info[self.entity_description.key]
diff --git a/homeassistant/components/hdfury/strings.json b/homeassistant/components/hdfury/strings.json
index 0aa0d5241a2..d2f1746b211 100644
--- a/homeassistant/components/hdfury/strings.json
+++ b/homeassistant/components/hdfury/strings.json
@@ -57,6 +57,50 @@
}
}
},
+ "sensor": {
+ "aud0": {
+ "name": "Audio TX0"
+ },
+ "aud1": {
+ "name": "Audio TX1"
+ },
+ "audout": {
+ "name": "Audio output"
+ },
+ "earcrx": {
+ "name": "eARC/ARC status"
+ },
+ "edida0": {
+ "name": "EDID TXA0"
+ },
+ "edida1": {
+ "name": "EDID TXA1"
+ },
+ "edida2": {
+ "name": "EDID AUDA"
+ },
+ "rx0": {
+ "name": "Input RX0"
+ },
+ "rx1": {
+ "name": "Input RX1"
+ },
+ "sink0": {
+ "name": "EDID TX0"
+ },
+ "sink1": {
+ "name": "EDID TX1"
+ },
+ "sink2": {
+ "name": "EDID AUD"
+ },
+ "tx0": {
+ "name": "Output TX0"
+ },
+ "tx1": {
+ "name": "Output TX1"
+ }
+ },
"switch": {
"autosw": {
"name": "Auto switch inputs"
diff --git a/homeassistant/components/hdfury/switch.py b/homeassistant/components/hdfury/switch.py
index 717aa345f02..066333b196c 100644
--- a/homeassistant/components/hdfury/switch.py
+++ b/homeassistant/components/hdfury/switch.py
@@ -16,6 +16,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HDFurySwitchEntityDescription(SwitchEntityDescription):
diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py
index e3089a6453c..4c044451659 100644
--- a/homeassistant/components/hikvision/__init__.py
+++ b/homeassistant/components/hikvision/__init__.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
+from pyhik.constants import SENSOR_MAP
from pyhik.hikvision import HikCamera
import requests
@@ -19,10 +20,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-PLATFORMS = [Platform.BINARY_SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA]
@dataclass
@@ -70,19 +74,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
+ _LOGGER.debug(
+ "Device %s (type=%s) initial event_states: %s",
+ device_name,
+ device_type,
+ camera.current_event_states,
+ )
+
# For NVRs or devices with no detected events, try to fetch events from ISAPI
+ # Use broader notification methods for NVRs since they often use 'record' etc.
if device_type == "NVR" or not camera.current_event_states:
+ nvr_notification_methods = {"center", "HTTP", "record", "email", "beep"}
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
- if nvr_events := camera.get_event_triggers():
- camera.inject_events(nvr_events)
+ nvr_events = camera.get_event_triggers(nvr_notification_methods)
+ _LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events)
+ if nvr_events:
+ # Map raw event type names to friendly names using SENSOR_MAP
+ mapped_events: dict[str, list[int]] = {}
+ for event_type, channels in nvr_events.items():
+ friendly_name = SENSOR_MAP.get(event_type.lower(), event_type)
+ if friendly_name in mapped_events:
+ mapped_events[friendly_name].extend(channels)
+ else:
+ mapped_events[friendly_name] = list(channels)
+ _LOGGER.debug("Mapped NVR events: %s", mapped_events)
+ camera.inject_events(mapped_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)
+ # Register the main device before platforms that use via_device
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, device_id)},
+ name=device_name,
+ manufacturer="Hikvision",
+ model=device_type,
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
index 6a354458ed3..a529185eda2 100644
--- a/homeassistant/components/hikvision/binary_sensor.py
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -185,19 +185,30 @@ class HikvisionBinarySensor(BinarySensorEntity):
# Build unique ID
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
- # Build entity name based on device type
- if self._data.device_type == "NVR":
- self._attr_name = f"{sensor_type} {channel}"
- else:
- self._attr_name = sensor_type
-
# Device info for device registry
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self._data.device_id)},
- name=self._data.device_name,
- manufacturer="Hikvision",
- model=self._data.device_type,
- )
+ if self._data.device_type == "NVR":
+ # NVR channels get their own device linked to the NVR via via_device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
+ via_device=(DOMAIN, self._data.device_id),
+ translation_key="nvr_channel",
+ translation_placeholders={
+ "device_name": self._data.device_name,
+ "channel_number": str(channel),
+ },
+ manufacturer="Hikvision",
+ model="NVR Channel",
+ )
+ self._attr_name = sensor_type
+ else:
+ # Single camera device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._data.device_id)},
+ name=self._data.device_name,
+ manufacturer="Hikvision",
+ model=self._data.device_type,
+ )
+ self._attr_name = sensor_type
# Set device class
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)
diff --git a/homeassistant/components/hikvision/camera.py b/homeassistant/components/hikvision/camera.py
new file mode 100644
index 00000000000..c369c6d3c41
--- /dev/null
+++ b/homeassistant/components/hikvision/camera.py
@@ -0,0 +1,97 @@
+"""Support for Hikvision cameras."""
+
+from __future__ import annotations
+
+from homeassistant.components.camera import Camera, CameraEntityFeature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HikvisionConfigEntry
+from .const import DOMAIN
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HikvisionConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Hikvision cameras from a config entry."""
+ data = entry.runtime_data
+ camera = data.camera
+
+ # Get available channels from the library
+ channels = await hass.async_add_executor_job(camera.get_channels)
+
+ if channels:
+ entities = [HikvisionCamera(entry, channel) for channel in channels]
+ else:
+ # Fallback to single camera if no channels detected
+ entities = [HikvisionCamera(entry, 1)]
+
+ async_add_entities(entities)
+
+
+class HikvisionCamera(Camera):
+ """Representation of a Hikvision camera."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_supported_features = CameraEntityFeature.STREAM
+
+ def __init__(
+ self,
+ entry: HikvisionConfigEntry,
+ channel: int,
+ ) -> None:
+ """Initialize the camera."""
+ super().__init__()
+ self._data = entry.runtime_data
+ self._channel = channel
+ self._camera = self._data.camera
+
+ # Build unique ID (unique per platform per integration)
+ self._attr_unique_id = f"{self._data.device_id}_{channel}"
+
+ # Device info for device registry
+ if self._data.device_type == "NVR":
+ # NVR channels get their own device linked to the NVR via via_device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
+ via_device=(DOMAIN, self._data.device_id),
+ translation_key="nvr_channel",
+ translation_placeholders={
+ "device_name": self._data.device_name,
+ "channel_number": str(channel),
+ },
+ manufacturer="Hikvision",
+ model="NVR Channel",
+ )
+ else:
+ # Single camera device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._data.device_id)},
+ name=self._data.device_name,
+ manufacturer="Hikvision",
+ model=self._data.device_type,
+ )
+
+ async def async_camera_image(
+ self, width: int | None = None, height: int | None = None
+ ) -> bytes | None:
+ """Return a still image from the camera."""
+ try:
+ return await self.hass.async_add_executor_job(
+ self._camera.get_snapshot, self._channel
+ )
+ except Exception as err:
+ raise HomeAssistantError(
+ f"Error getting image from {self._data.device_name} channel {self._channel}: {err}"
+ ) from err
+
+ async def stream_source(self) -> str | None:
+ """Return the stream source URL."""
+ return self._camera.get_stream_url(self._channel)
diff --git a/homeassistant/components/hikvision/strings.json b/homeassistant/components/hikvision/strings.json
index ca25cccf772..0b5241bdd29 100644
--- a/homeassistant/components/hikvision/strings.json
+++ b/homeassistant/components/hikvision/strings.json
@@ -29,6 +29,11 @@
}
}
},
+ "device": {
+ "nvr_channel": {
+ "name": "{device_name} channel {channel_number}"
+ }
+ },
"issues": {
"deprecated_yaml_import_issue": {
"description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.",
diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py
index 71508c5d669..5fc19e9bfdf 100644
--- a/homeassistant/components/homee/sensor.py
+++ b/homeassistant/components/homee/sensor.py
@@ -6,10 +6,7 @@ from dataclasses import dataclass
from pyHomee.const import AttributeType, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode
-from homeassistant.components.automation import automations_with_entity
-from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
- DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -17,17 +14,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.issue_registry import (
- IssueSeverity,
- async_create_issue,
- async_delete_issue,
-)
from . import HomeeConfigEntry
from .const import (
- DOMAIN,
HOMEE_UNIT_TO_HA_UNIT,
OPEN_CLOSE_MAP,
OPEN_CLOSE_MAP_REVERSED,
@@ -109,11 +99,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
- AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription(
- key="valve_position",
- entity_category=EntityCategory.DIAGNOSTIC,
- state_class=SensorStateClass.MEASUREMENT,
- ),
AttributeType.DAWN: HomeeSensorEntityDescription(
key="dawn",
device_class=SensorDeviceClass.ILLUMINANCE,
@@ -294,57 +279,12 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
)
-def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
- """Get list of related automations and scripts."""
- used_in = automations_with_entity(hass, entity_id)
- used_in += scripts_with_entity(hass, entity_id)
- return used_in
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
- ent_reg = er.async_get(hass)
-
- def add_deprecated_entity(
- attribute: HomeeAttribute, description: HomeeSensorEntityDescription
- ) -> list[HomeeSensor]:
- """Add deprecated entities."""
- deprecated_entities: list[HomeeSensor] = []
- entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
- if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid):
- entity_entry = ent_reg.async_get(entity_id)
- if entity_entry and entity_entry.disabled:
- ent_reg.async_remove(entity_id)
- async_delete_issue(
- hass,
- DOMAIN,
- f"deprecated_entity_{entity_uid}",
- )
- elif entity_entry:
- deprecated_entities.append(
- HomeeSensor(attribute, config_entry, description)
- )
- if entity_used_in(hass, entity_id):
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_entity_{entity_uid}",
- breaks_in_ha_version="2025.12.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_entity",
- translation_placeholders={
- "name": str(
- entity_entry.name or entity_entry.original_name
- ),
- "entity": entity_id,
- },
- )
- return deprecated_entities
async def add_sensor_entities(
config_entry: HomeeConfigEntry,
@@ -362,19 +302,13 @@ async def async_setup_entry(
)
# Node attributes that are sensors.
- for attribute in node.attributes:
- if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
- entities.extend(
- add_deprecated_entity(
- attribute, SENSOR_DESCRIPTIONS[attribute.type]
- )
- )
- elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
- entities.append(
- HomeeSensor(
- attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
- )
- )
+ entities.extend(
+ HomeeSensor(
+ attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
+ )
+ for attribute in node.attributes
+ if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
+ )
if entities:
async_add_entities(entities)
diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json
index 535c5b9628a..9187c9956c7 100644
--- a/homeassistant/components/homee/strings.json
+++ b/homeassistant/components/homee/strings.json
@@ -495,11 +495,5 @@
"invalid_preset_mode": {
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
}
- },
- "issues": {
- "deprecated_entity": {
- "description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
- "title": "The Homee {name} entity is deprecated"
- }
}
}
diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py
index b9840fb2b68..06fc0a1c493 100644
--- a/homeassistant/components/homekit/accessories.py
+++ b/homeassistant/components/homekit/accessories.py
@@ -220,31 +220,33 @@ def get_accessory( # noqa: C901
a_type = "TemperatureSensor"
elif device_class == SensorDeviceClass.HUMIDITY and unit == PERCENTAGE:
a_type = "HumiditySensor"
- elif (
- device_class == SensorDeviceClass.PM10
- or SensorDeviceClass.PM10 in state.entity_id
- ):
+ elif device_class == SensorDeviceClass.PM10:
a_type = "PM10Sensor"
- elif (
- device_class == SensorDeviceClass.PM25
- or SensorDeviceClass.PM25 in state.entity_id
- ):
+ elif device_class == SensorDeviceClass.PM25:
a_type = "PM25Sensor"
elif device_class == SensorDeviceClass.NITROGEN_DIOXIDE:
a_type = "NitrogenDioxideSensor"
elif device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS:
a_type = "VolatileOrganicCompoundsSensor"
- elif (
- device_class == SensorDeviceClass.GAS
- or SensorDeviceClass.GAS in state.entity_id
- ):
+ elif device_class == SensorDeviceClass.GAS:
a_type = "AirQualitySensor"
elif device_class == SensorDeviceClass.CO:
a_type = "CarbonMonoxideSensor"
- elif device_class == SensorDeviceClass.CO2 or "co2" in state.entity_id:
+ elif device_class == SensorDeviceClass.CO2:
a_type = "CarbonDioxideSensor"
elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX:
a_type = "LightSensor"
+
+ # Fallbacks based on entity_id
+ elif SensorDeviceClass.PM10 in state.entity_id:
+ a_type = "PM10Sensor"
+ elif SensorDeviceClass.PM25 in state.entity_id:
+ a_type = "PM25Sensor"
+ elif SensorDeviceClass.GAS in state.entity_id:
+ a_type = "AirQualitySensor"
+ elif "co2" in state.entity_id:
+ a_type = "CarbonDioxideSensor"
+
else:
_LOGGER.debug(
"%s: Unsupported sensor type (device_class=%s) (unit=%s)",
diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py
index 0d94c2bb78b..e2090b74ce8 100644
--- a/homeassistant/components/homematic/binary_sensor.py
+++ b/homeassistant/components/homematic/binary_sensor.py
@@ -59,21 +59,21 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
"""Representation of a binary HomeMatic device."""
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if switch is on."""
if not self.available:
return False
return bool(self._hm_get_state())
@property
- def device_class(self):
+ def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the class of this sensor from DEVICE_CLASSES."""
# If state is MOTION (Only RemoteMotion working)
if self._state == "MOTION":
return BinarySensorDeviceClass.MOTION
return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__)
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:
@@ -86,11 +86,11 @@ class HMBatterySensor(HMDevice, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.BATTERY
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return True if battery is low."""
return bool(self._hm_get_state())
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:
diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py
index 28943774b6c..096ad76db11 100644
--- a/homeassistant/components/homematic/climate.py
+++ b/homeassistant/components/homematic/climate.py
@@ -178,7 +178,7 @@ class HMThermostat(HMDevice, ClimateEntity):
# Homematic
return self._data.get("CONTROL_MODE")
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dict (self._data) from the Homematic metadata."""
self._state = next(iter(self._hmdevice.WRITENODE.keys()))
self._data[self._state] = None
diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py
index b9f4a4fa96a..f93d92eed56 100644
--- a/homeassistant/components/homematic/cover.py
+++ b/homeassistant/components/homematic/cover.py
@@ -78,7 +78,7 @@ class HMCover(HMDevice, CoverEntity):
"""Stop the device if in motion."""
self._hmdevice.stop(self._channel)
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
self._state = "LEVEL"
self._data.update({self._state: None})
@@ -138,7 +138,7 @@ class HMGarage(HMCover):
"""Return whether the cover is closed."""
return self._hmdevice.is_closed(self._hm_get_state())
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
self._state = "DOOR_STATE"
self._data.update({self._state: None})
diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py
index 3b5d2ebb509..3e4d6a6fc71 100644
--- a/homeassistant/components/homematic/entity.py
+++ b/homeassistant/components/homematic/entity.py
@@ -204,7 +204,7 @@ class HMDevice(Entity):
self._init_data_struct()
@abstractmethod
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dictionary from the HomeMatic device metadata."""
diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py
index 838cdc9c3c3..62ce1cc9457 100644
--- a/homeassistant/components/homematic/light.py
+++ b/homeassistant/components/homematic/light.py
@@ -51,7 +51,7 @@ class HMLight(HMDevice, LightEntity):
_attr_max_color_temp_kelvin = 6500 # 153 Mireds
@property
- def brightness(self):
+ def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
# Is dimmer?
if self._state == "LEVEL":
@@ -59,7 +59,7 @@ class HMLight(HMDevice, LightEntity):
return None
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if light is on."""
try:
return self._hm_get_state() > 0
@@ -98,7 +98,7 @@ class HMLight(HMDevice, LightEntity):
return features
@property
- def hs_color(self):
+ def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
if ColorMode.HS not in self.supported_color_modes:
return None
@@ -116,14 +116,14 @@ class HMLight(HMDevice, LightEntity):
)
@property
- def effect_list(self):
+ def effect_list(self) -> list[str] | None:
"""Return the list of supported effects."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
return self._hmdevice.get_effect_list()
@property
- def effect(self):
+ def effect(self) -> str | None:
"""Return the current color change program of the light."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
@@ -166,7 +166,7 @@ class HMLight(HMDevice, LightEntity):
self._hmdevice.off(self._channel)
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dict (self._data) from the Homematic metadata."""
# Use LEVEL
self._state = "LEVEL"
diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py
index b79f28f2bc7..7640146b422 100644
--- a/homeassistant/components/homematic/lock.py
+++ b/homeassistant/components/homematic/lock.py
@@ -48,7 +48,7 @@ class HMLock(HMDevice, LockEntity):
"""Open the door latch."""
self._hmdevice.open()
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})
diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py
index 1f89abea5cc..b4a2692a417 100644
--- a/homeassistant/components/homematic/notify.py
+++ b/homeassistant/components/homematic/notify.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import Any
+
import voluptuous as vol
from homeassistant.components.notify import (
@@ -60,7 +62,7 @@ class HomematicNotificationService(BaseNotificationService):
self.hass = hass
self.data = data
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a notification to the device."""
data = {**self.data, **kwargs.get(ATTR_DATA, {})}
diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py
index bdd446d7091..0ddc319626e 100644
--- a/homeassistant/components/homematic/sensor.py
+++ b/homeassistant/components/homematic/sensor.py
@@ -339,7 +339,7 @@ class HMSensor(HMDevice, SensorEntity):
# No cast, return original value
return self._hm_get_state()
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
if self._state:
self._data.update({self._state: None})
diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py
index 5f7c1f93dc8..ac8a2e5fe14 100644
--- a/homeassistant/components/homematic/switch.py
+++ b/homeassistant/components/homematic/switch.py
@@ -35,7 +35,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Representation of a HomeMatic switch."""
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return True if switch is on."""
try:
return self._hm_get_state() > 0
@@ -43,7 +43,7 @@ class HMSwitch(HMDevice, SwitchEntity):
return False
@property
- def today_energy_kwh(self):
+ def today_energy_kwh(self) -> float | None:
"""Return the current power usage in kWh."""
if "ENERGY_COUNTER" in self._data:
try:
@@ -61,7 +61,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Turn the switch off."""
self._hmdevice.off(self._channel)
- def _init_data_struct(self):
+ def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index 63500eb1f71..c008ec02b0a 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==10.0.0"],
+ "requirements": ["python-homewizard-energy==10.0.1"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index e9ebdb9da67..859a7b7e567 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -9,6 +9,7 @@ from http import HTTPStatus
import json
import logging
import time
+from typing import Any
from urllib.parse import urlparse
import uuid
@@ -451,7 +452,7 @@ class HTML5NotificationService(BaseNotificationService):
"""
await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs))
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
tag = str(uuid.uuid4())
payload = {
diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py
index fb8f3c572c1..0ea079992e0 100644
--- a/homeassistant/components/hue/v1/sensor_base.py
+++ b/homeassistant/components/hue/v1/sensor_base.py
@@ -181,7 +181,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass
)
@property
- def state_class(self):
+ def state_class(self) -> SensorStateClass:
"""Return the state class of this entity, from STATE_CLASSES, if any."""
return SensorStateClass.MEASUREMENT
diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json
index 318be5cca98..ea8f52732cf 100644
--- a/homeassistant/components/icloud/manifest.json
+++ b/homeassistant/components/icloud/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
- "requirements": ["pyicloud==2.2.0"]
+ "requirements": ["pyicloud==2.3.0"]
}
diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py
index a3432b96b13..6a1e7bb8e6d 100644
--- a/homeassistant/components/joaoapps_join/notify.py
+++ b/homeassistant/components/joaoapps_join/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from pyjoin import get_devices, send_notification
import voluptuous as vol
@@ -66,7 +67,7 @@ class JoinNotificationService(BaseNotificationService):
self._device_ids = device_ids
self._device_names = device_names
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
data = kwargs.get(ATTR_DATA) or {}
diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py
index ad7e333ca13..547b0a67761 100644
--- a/homeassistant/components/jvc_projector/__init__.py
+++ b/homeassistant/components/jvc_projector/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
+from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorTimeoutError
from homeassistant.const import (
CONF_HOST,
@@ -11,8 +11,9 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
-from homeassistant.core import Event, HomeAssistant
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
@@ -28,8 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
)
try:
- await device.connect(True)
- except JvcProjectorConnectError as err:
+ await device.connect()
+ except JvcProjectorTimeoutError as err:
await device.disconnect()
raise ConfigEntryNotReady(
f"Unable to connect to {entry.data[CONF_HOST]}"
@@ -50,6 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
)
+ await async_migrate_entities(hass, entry, coordinator)
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -60,3 +63,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.device.disconnect()
return unload_ok
+
+
+async def async_migrate_entities(
+ hass: HomeAssistant,
+ config_entry: JVCConfigEntry,
+ coordinator: JvcProjectorDataUpdateCoordinator,
+) -> None:
+ """Migrate old entities as needed."""
+
+ @callback
+ def _update_entry(entry: RegistryEntry) -> dict[str, str] | None:
+ """Fix unique_id of power binary_sensor entry."""
+ if entry.domain == Platform.BINARY_SENSOR and ":" not in entry.unique_id:
+ if "_power" in entry.unique_id:
+ return {"new_unique_id": f"{coordinator.unique_id}_power"}
+ return None
+
+ await async_migrate_entries(hass, config_entry.entry_id, _update_entry)
diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py
index 7ae76298839..86e3e104f32 100644
--- a/homeassistant/components/jvc_projector/binary_sensor.py
+++ b/homeassistant/components/jvc_projector/binary_sensor.py
@@ -2,16 +2,17 @@
from __future__ import annotations
-from jvcprojector import const
+from jvcprojector import command as cmd
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import POWER
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
-ON_STATUS = (const.ON, const.WARMING)
+ON_STATUS = (cmd.Power.ON, cmd.Power.WARMING)
async def async_setup_entry(
@@ -21,14 +22,13 @@ async def async_setup_entry(
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
-
async_add_entities([JvcBinarySensor(coordinator)])
class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
"""The entity class for JVC Projector Binary Sensor."""
- _attr_translation_key = "jvc_power"
+ _attr_translation_key = "power"
def __init__(
self,
@@ -36,9 +36,9 @@ class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
) -> None:
"""Initialize the JVC Projector sensor."""
super().__init__(coordinator)
- self._attr_unique_id = f"{coordinator.device.mac}_power"
+ self._attr_unique_id = f"{coordinator.unique_id}_power"
@property
def is_on(self) -> bool:
- """Return true if the JVC is on."""
- return self.coordinator.data["power"] in ON_STATUS
+ """Return true if the JVC Projector is on."""
+ return self.coordinator.data[POWER] in ON_STATUS
diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py
index 5d9bedd7591..26131f687c2 100644
--- a/homeassistant/components/jvc_projector/config_flow.py
+++ b/homeassistant/components/jvc_projector/config_flow.py
@@ -5,7 +5,12 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
+from jvcprojector import (
+ JvcProjector,
+ JvcProjectorAuthError,
+ JvcProjectorTimeoutError,
+ command as cmd,
+)
from jvcprojector.projector import DEFAULT_PORT
import voluptuous as vol
@@ -40,7 +45,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
mac = await get_mac_address(host, port, password)
except InvalidHost:
errors["base"] = "invalid_host"
- except JvcProjectorConnectError:
+ except JvcProjectorTimeoutError:
errors["base"] = "cannot_connect"
except JvcProjectorAuthError:
errors["base"] = "invalid_auth"
@@ -91,7 +96,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await get_mac_address(host, port, password)
- except JvcProjectorConnectError:
+ except JvcProjectorTimeoutError:
errors["base"] = "cannot_connect"
except JvcProjectorAuthError:
errors["base"] = "invalid_auth"
@@ -115,7 +120,7 @@ async def get_mac_address(host: str, port: int, password: str | None) -> str:
"""Get device mac address for config flow."""
device = JvcProjector(host, port=port, password=password)
try:
- await device.connect(True)
+ await device.connect()
+ return await device.get(cmd.MacAddress)
finally:
await device.disconnect()
- return device.mac
diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py
index e15aa93bfa5..d0dbd1f73f8 100644
--- a/homeassistant/components/jvc_projector/const.py
+++ b/homeassistant/components/jvc_projector/const.py
@@ -3,3 +3,7 @@
NAME = "JVC Projector"
DOMAIN = "jvc_projector"
MANUFACTURER = "JVC"
+
+POWER = "power"
+INPUT = "input"
+SOURCE = "source"
diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py
index db97b05f980..58ca14a3738 100644
--- a/homeassistant/components/jvc_projector/coordinator.py
+++ b/homeassistant/components/jvc_projector/coordinator.py
@@ -4,22 +4,21 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from jvcprojector import (
JvcProjector,
JvcProjectorAuthError,
- JvcProjectorConnectError,
- const,
+ JvcProjectorTimeoutError,
+ command as cmd,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import NAME
+from .const import INPUT, NAME, POWER
_LOGGER = logging.getLogger(__name__)
@@ -46,26 +45,33 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
update_interval=INTERVAL_SLOW,
)
- self.device = device
- self.unique_id = format_mac(device.mac)
+ self.device: JvcProjector = device
+
+ if TYPE_CHECKING:
+ assert config_entry.unique_id is not None
+ self.unique_id = config_entry.unique_id
async def _async_update_data(self) -> dict[str, Any]:
"""Get the latest state data."""
+ state: dict[str, str | None] = {
+ POWER: None,
+ INPUT: None,
+ }
+
try:
- state = await self.device.get_state()
- except JvcProjectorConnectError as err:
+ state[POWER] = await self.device.get(cmd.Power)
+
+ if state[POWER] == cmd.Power.ON:
+ state[INPUT] = await self.device.get(cmd.Input)
+
+ except JvcProjectorTimeoutError as err:
raise UpdateFailed(f"Unable to connect to {self.device.host}") from err
except JvcProjectorAuthError as err:
raise ConfigEntryAuthFailed("Password authentication failed") from err
- old_interval = self.update_interval
-
- if state[const.POWER] != const.STANDBY:
+ if state[POWER] != cmd.Power.STANDBY:
self.update_interval = INTERVAL_FAST
else:
self.update_interval = INTERVAL_SLOW
- if self.update_interval != old_interval:
- _LOGGER.debug("Changed update interval to %s", self.update_interval)
-
return state
diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py
index a88fba03cb0..317bc5ce654 100644
--- a/homeassistant/components/jvc_projector/entity.py
+++ b/homeassistant/components/jvc_projector/entity.py
@@ -26,7 +26,7 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
self._attr_unique_id = coordinator.unique_id
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.unique_id)},
+ identifiers={(DOMAIN, self._attr_unique_id)},
name=NAME,
model=self.device.model,
manufacturer=MANUFACTURER,
diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json
index 530373850eb..46f04de7094 100644
--- a/homeassistant/components/jvc_projector/manifest.json
+++ b/homeassistant/components/jvc_projector/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
- "requirements": ["pyjvcprojector==1.1.3"]
+ "requirements": ["pyjvcprojector==2.0.0"]
}
diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py
index 22855a9f801..f2e436f41d0 100644
--- a/homeassistant/components/jvc_projector/remote.py
+++ b/homeassistant/components/jvc_projector/remote.py
@@ -7,54 +7,62 @@ from collections.abc import Iterable
import logging
from typing import Any
-from jvcprojector import const
+from jvcprojector import command as cmd
from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import POWER
from .coordinator import JVCConfigEntry
from .entity import JvcProjectorEntity
-COMMANDS = {
- "menu": const.REMOTE_MENU,
- "up": const.REMOTE_UP,
- "down": const.REMOTE_DOWN,
- "left": const.REMOTE_LEFT,
- "right": const.REMOTE_RIGHT,
- "ok": const.REMOTE_OK,
- "back": const.REMOTE_BACK,
- "mpc": const.REMOTE_MPC,
- "hide": const.REMOTE_HIDE,
- "info": const.REMOTE_INFO,
- "input": const.REMOTE_INPUT,
- "cmd": const.REMOTE_CMD,
- "advanced_menu": const.REMOTE_ADVANCED_MENU,
- "picture_mode": const.REMOTE_PICTURE_MODE,
- "color_profile": const.REMOTE_COLOR_PROFILE,
- "lens_control": const.REMOTE_LENS_CONTROL,
- "setting_memory": const.REMOTE_SETTING_MEMORY,
- "gamma_settings": const.REMOTE_GAMMA_SETTINGS,
- "hdmi_1": const.REMOTE_HDMI_1,
- "hdmi_2": const.REMOTE_HDMI_2,
- "mode_1": const.REMOTE_MODE_1,
- "mode_2": const.REMOTE_MODE_2,
- "mode_3": const.REMOTE_MODE_3,
- "mode_4": const.REMOTE_MODE_4,
- "mode_5": const.REMOTE_MODE_5,
- "mode_6": const.REMOTE_MODE_6,
- "mode_7": const.REMOTE_MODE_7,
- "mode_8": const.REMOTE_MODE_8,
- "mode_9": const.REMOTE_MODE_9,
- "mode_10": const.REMOTE_MODE_10,
- "lens_ap": const.REMOTE_LENS_AP,
- "gamma": const.REMOTE_GAMMA,
- "color_temp": const.REMOTE_COLOR_TEMP,
- "natural": const.REMOTE_NATURAL,
- "cinema": const.REMOTE_CINEMA,
- "anamo": const.REMOTE_ANAMO,
- "3d_format": const.REMOTE_3D_FORMAT,
+COMMANDS: list[str] = [
+ cmd.Remote.MENU,
+ cmd.Remote.UP,
+ cmd.Remote.DOWN,
+ cmd.Remote.LEFT,
+ cmd.Remote.RIGHT,
+ cmd.Remote.OK,
+ cmd.Remote.BACK,
+ cmd.Remote.MPC,
+ cmd.Remote.HIDE,
+ cmd.Remote.INFO,
+ cmd.Remote.INPUT,
+ cmd.Remote.CMD,
+ cmd.Remote.ADVANCED_MENU,
+ cmd.Remote.PICTURE_MODE,
+ cmd.Remote.COLOR_PROFILE,
+ cmd.Remote.LENS_CONTROL,
+ cmd.Remote.SETTING_MEMORY,
+ cmd.Remote.GAMMA_SETTINGS,
+ cmd.Remote.HDMI1,
+ cmd.Remote.HDMI2,
+ cmd.Remote.MODE_1,
+ cmd.Remote.MODE_2,
+ cmd.Remote.MODE_3,
+ cmd.Remote.MODE_4,
+ cmd.Remote.MODE_5,
+ cmd.Remote.MODE_6,
+ cmd.Remote.MODE_7,
+ cmd.Remote.MODE_8,
+ cmd.Remote.MODE_9,
+ cmd.Remote.MODE_10,
+ cmd.Remote.GAMMA,
+ cmd.Remote.NATURAL,
+ cmd.Remote.CINEMA,
+ cmd.Remote.COLOR_TEMP,
+ cmd.Remote.ANAMORPHIC,
+ cmd.Remote.LENS_APERTURE,
+ cmd.Remote.V3D_FORMAT,
+]
+
+RENAMED_COMMANDS: dict[str, str] = {
+ "anamo": cmd.Remote.ANAMORPHIC,
+ "lens_ap": cmd.Remote.LENS_APERTURE,
+ "hdmi1": cmd.Remote.HDMI1,
+ "hdmi2": cmd.Remote.HDMI2,
}
_LOGGER = logging.getLogger(__name__)
@@ -77,25 +85,34 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
@property
def is_on(self) -> bool:
- """Return True if entity is on."""
- return self.coordinator.data["power"] in [const.ON, const.WARMING]
+ """Return True if the entity is on."""
+ return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
- await self.device.power_on()
+ await self.device.set(cmd.Power, cmd.Power.ON)
await asyncio.sleep(1)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- await self.device.power_off()
+ await self.device.set(cmd.Power, cmd.Power.OFF)
await asyncio.sleep(1)
await self.coordinator.async_refresh()
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a remote command to the device."""
- for cmd in command:
- if cmd not in COMMANDS:
- raise HomeAssistantError(f"{cmd} is not a known command")
- _LOGGER.debug("Sending command '%s'", cmd)
- await self.device.remote(COMMANDS[cmd])
+ for send_command in command:
+ # Legacy name replace
+ if send_command in RENAMED_COMMANDS:
+ send_command = RENAMED_COMMANDS[send_command]
+
+ # Legacy name fixup
+ if "_" in send_command:
+ send_command = send_command.replace("_", "-")
+
+ if send_command not in COMMANDS:
+ raise HomeAssistantError(f"{send_command} is not a known command")
+
+ _LOGGER.debug("Sending command '%s'", send_command)
+ await self.device.remote(send_command)
diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py
index b83695609cb..861c2846a0a 100644
--- a/homeassistant/components/jvc_projector/select.py
+++ b/homeassistant/components/jvc_projector/select.py
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final
-from jvcprojector import JvcProjector, const
+from jvcprojector import JvcProjector, command as cmd
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -23,16 +23,12 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
command: Callable[[JvcProjector, str], Awaitable[None]]
-OPTIONS: Final[dict[str, dict[str, str]]] = {
- "input": {const.HDMI1: const.REMOTE_HDMI_1, const.HDMI2: const.REMOTE_HDMI_2}
-}
-
SELECTS: Final[list[JvcProjectorSelectDescription]] = [
JvcProjectorSelectDescription(
key="input",
translation_key="input",
- options=list(OPTIONS["input"]),
- command=lambda device, option: device.remote(OPTIONS["input"][option]),
+ options=[cmd.Input.HDMI1, cmd.Input.HDMI2],
+ command=lambda device, option: device.set(cmd.Input, option),
)
]
diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py
index 7a7799bc4ee..dd0c16e6fff 100644
--- a/homeassistant/components/jvc_projector/sensor.py
+++ b/homeassistant/components/jvc_projector/sensor.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from jvcprojector import const
+from jvcprojector import command as cmd
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -23,11 +23,11 @@ JVC_SENSORS = (
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
- const.STANDBY,
- const.ON,
- const.WARMING,
- const.COOLING,
- const.ERROR,
+ cmd.Power.STANDBY,
+ cmd.Power.ON,
+ cmd.Power.WARMING,
+ cmd.Power.COOLING,
+ cmd.Power.ERROR,
],
),
)
diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json
index a536b2f282a..89c54ce5f2c 100644
--- a/homeassistant/components/jvc_projector/strings.json
+++ b/homeassistant/components/jvc_projector/strings.json
@@ -35,7 +35,7 @@
},
"entity": {
"binary_sensor": {
- "jvc_power": {
+ "power": {
"name": "[%key:component::binary_sensor::entity_component::power::name%]"
}
},
@@ -50,7 +50,7 @@
},
"sensor": {
"jvc_power_status": {
- "name": "Power status",
+ "name": "Status",
"state": {
"cooling": "Cooling",
"error": "[%key:common::state::error%]",
diff --git a/homeassistant/components/keba/notify.py b/homeassistant/components/keba/notify.py
index 5358ba32ff9..3495e46649c 100644
--- a/homeassistant/components/keba/notify.py
+++ b/homeassistant/components/keba/notify.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import Any
+
from homeassistant.components.notify import ATTR_DATA, BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -27,7 +29,7 @@ class KebaNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
- async def async_send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send the message."""
text = message.replace(" ", "$") # Will be translated back by the display
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index ead846735c9..cf91107852a 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -27,7 +27,7 @@ from .const import (
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
-from .expose import create_knx_exposure
+from .expose import create_combined_knx_exposure
from .knx_module import KNXModule
from .project import STORAGE_KEY as PROJECT_STORAGE_KEY
from .schema import (
@@ -121,10 +121,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[KNX_MODULE_KEY] = knx_module
if CONF_KNX_EXPOSE in config:
- for expose_config in config[CONF_KNX_EXPOSE]:
- knx_module.exposures.append(
- create_knx_exposure(hass, knx_module.xknx, expose_config)
- )
+ knx_module.yaml_exposures.extend(
+ create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
+ )
+
configured_platforms_yaml = {
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
}
@@ -149,7 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# if not loaded directly return
return True
- for exposure in knx_module.exposures:
+ for exposure in knx_module.yaml_exposures:
+ exposure.async_remove()
+ for exposure in knx_module.service_exposures.values():
exposure.async_remove()
configured_platforms_yaml = {
diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py
index 0a42b6018ba..e8d44385e42 100644
--- a/homeassistant/components/knx/expose.py
+++ b/homeassistant/components/knx/expose.py
@@ -2,14 +2,22 @@
from __future__ import annotations
-from collections.abc import Callable
+from asyncio import TaskGroup
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass
import logging
+from typing import Any
from xknx import XKNX
from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
-from xknx.dpt import DPTNumeric, DPTString
+from xknx.dpt import DPTBase, DPTNumeric, DPTString
+from xknx.dpt.dpt_1 import DPT1BitEnum, DPTSwitch
from xknx.exceptions import ConversionError
-from xknx.remote_value import RemoteValueSensor
+from xknx.telegram.address import (
+ GroupAddress,
+ InternalGroupAddress,
+ parse_device_group_address,
+)
from homeassistant.const import (
CONF_ENTITY_ID,
@@ -41,79 +49,159 @@ _LOGGER = logging.getLogger(__name__)
@callback
def create_knx_exposure(
hass: HomeAssistant, xknx: XKNX, config: ConfigType
-) -> KNXExposeSensor | KNXExposeTime:
- """Create exposures from config."""
-
+) -> KnxExposeEntity | KnxExposeTime:
+ """Create single exposure."""
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
-
- exposure: KNXExposeSensor | KNXExposeTime
+ exposure: KnxExposeEntity | KnxExposeTime
if (
isinstance(expose_type, str)
and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES
):
- exposure = KNXExposeTime(
+ exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
else:
- exposure = KNXExposeSensor(
- hass,
+ exposure = KnxExposeEntity(
+ hass=hass,
xknx=xknx,
- config=config,
+ entity_id=config[CONF_ENTITY_ID],
+ options=(_yaml_config_to_expose_options(config),),
)
exposure.async_register()
return exposure
-class KNXExposeSensor:
- """Object to Expose Home Assistant entity to KNX bus."""
+@callback
+def create_combined_knx_exposure(
+ hass: HomeAssistant, xknx: XKNX, configs: list[ConfigType]
+) -> list[KnxExposeEntity | KnxExposeTime]:
+ """Create exposures from YAML config combined by entity_id."""
+ exposures: list[KnxExposeEntity | KnxExposeTime] = []
+ entity_exposure_map: dict[str, list[KnxExposeOptions]] = {}
+
+ for config in configs:
+ value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
+ if value_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES:
+ time_exposure = KnxExposeTime(
+ xknx=xknx,
+ config=config,
+ )
+ time_exposure.async_register()
+ exposures.append(time_exposure)
+ continue
+
+ entity_id = config[CONF_ENTITY_ID]
+ option = _yaml_config_to_expose_options(config)
+ entity_exposure_map.setdefault(entity_id, []).append(option)
+
+ for entity_id, options in entity_exposure_map.items():
+ entity_exposure = KnxExposeEntity(
+ hass=hass,
+ xknx=xknx,
+ entity_id=entity_id,
+ options=options,
+ )
+ entity_exposure.async_register()
+ exposures.append(entity_exposure)
+ return exposures
+
+
+@dataclass(slots=True)
+class KnxExposeOptions:
+ """Options for KNX Expose."""
+
+ attribute: str | None
+ group_address: GroupAddress | InternalGroupAddress
+ dpt: type[DPTBase]
+ respond_to_read: bool
+ cooldown: float
+ default: Any | None
+ value_template: Template | None
+
+
+def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
+ """Convert single yaml expose config to KnxExposeOptions."""
+ value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
+ dpt: type[DPTBase]
+ if value_type == "binary":
+ # HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then)
+ dpt = DPTSwitch
+ else:
+ dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
+ ga = parse_device_group_address(config[KNX_ADDRESS])
+ return KnxExposeOptions(
+ attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
+ group_address=ga,
+ dpt=dpt,
+ respond_to_read=config[CONF_RESPOND_TO_READ],
+ cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
+ default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
+ value_template=config.get(CONF_VALUE_TEMPLATE),
+ )
+
+
+class KnxExposeEntity:
+ """Expose Home Assistant entity values to KNX bus."""
def __init__(
self,
hass: HomeAssistant,
xknx: XKNX,
- config: ConfigType,
+ entity_id: str,
+ options: Iterable[KnxExposeOptions],
) -> None:
- """Initialize of Expose class."""
+ """Initialize KnxExposeEntity class."""
self.hass = hass
self.xknx = xknx
-
- self.entity_id: str = config[CONF_ENTITY_ID]
- self.expose_attribute: str | None = config.get(
- ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE
- )
- self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
- self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
- self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
+ self.entity_id = entity_id
self._remove_listener: Callable[[], None] | None = None
- self.device: ExposeSensor = ExposeSensor(
- xknx=self.xknx,
- name=f"{self.entity_id}__{self.expose_attribute or 'state'}",
- group_address=config[KNX_ADDRESS],
- respond_to_read=config[CONF_RESPOND_TO_READ],
- value_type=self.expose_type,
- cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
+ self._exposures = tuple(
+ (
+ option,
+ ExposeSensor(
+ xknx=self.xknx,
+ name=f"{self.entity_id} {option.attribute or 'state'}",
+ group_address=option.group_address,
+ respond_to_read=option.respond_to_read,
+ value_type=option.dpt,
+ cooldown=option.cooldown,
+ ),
+ )
+ for option in options
)
+ @property
+ def name(self) -> str:
+ """Return name of the expose entity."""
+ expose_names = [opt.attribute or "state" for opt, _ in self._exposures]
+ return f"{self.entity_id}__{'__'.join(expose_names)}"
+
@callback
def async_register(self) -> None:
- """Register listener."""
+ """Register listener and XKNX devices."""
self._remove_listener = async_track_state_change_event(
self.hass, [self.entity_id], self._async_entity_changed
)
- self.xknx.devices.async_add(self.device)
+ for _option, xknx_expose in self._exposures:
+ self.xknx.devices.async_add(xknx_expose)
self._init_expose_state()
@callback
def _init_expose_state(self) -> None:
- """Initialize state of the exposure."""
+ """Initialize state of all exposures."""
init_state = self.hass.states.get(self.entity_id)
- state_value = self._get_expose_value(init_state)
- try:
- self.device.sensor_value.value = state_value
- except ConversionError:
- _LOGGER.exception("Error during sending of expose sensor value")
+ for option, xknx_expose in self._exposures:
+ state_value = self._get_expose_value(init_state, option)
+ try:
+ xknx_expose.sensor_value.value = state_value
+ except ConversionError:
+ _LOGGER.exception(
+ "Error setting value %s for expose sensor %s",
+ state_value,
+ xknx_expose.name,
+ )
@callback
def async_remove(self) -> None:
@@ -121,53 +209,57 @@ class KNXExposeSensor:
if self._remove_listener is not None:
self._remove_listener()
self._remove_listener = None
- self.xknx.devices.async_remove(self.device)
+ for _option, xknx_expose in self._exposures:
+ self.xknx.devices.async_remove(xknx_expose)
- def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
- """Extract value from state."""
+ def _get_expose_value(
+ self, state: State | None, option: KnxExposeOptions
+ ) -> bool | int | float | str | None:
+ """Extract value from state for a specific option."""
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
- if self.expose_default is None:
+ if option.default is None:
return None
- value = self.expose_default
- elif self.expose_attribute is not None:
- _attr = state.attributes.get(self.expose_attribute)
- value = _attr if _attr is not None else self.expose_default
+ value = option.default
+ elif option.attribute is not None:
+ _attr = state.attributes.get(option.attribute)
+ value = _attr if _attr is not None else option.default
else:
value = state.state
- if self.value_template is not None:
+ if option.value_template is not None:
try:
- value = self.value_template.async_render_with_possible_json_value(
+ value = option.value_template.async_render_with_possible_json_value(
value, error_value=None
)
except (TemplateError, TypeError, ValueError) as err:
_LOGGER.warning(
- "Error rendering value template for KNX expose %s %s: %s",
- self.device.name,
- self.value_template.template,
+ "Error rendering value template for KNX expose %s %s %s: %s",
+ self.entity_id,
+ option.attribute or "state",
+ option.value_template.template,
err,
)
return None
- if self.expose_type == "binary":
+ if issubclass(option.dpt, DPT1BitEnum):
if value in (1, STATE_ON, "True"):
return True
if value in (0, STATE_OFF, "False"):
return False
- if value is not None and (
- isinstance(self.device.sensor_value, RemoteValueSensor)
- ):
+
+ # Handle numeric and string DPT conversions
+ if value is not None:
try:
- if issubclass(self.device.sensor_value.dpt_class, DPTNumeric):
+ if issubclass(option.dpt, DPTNumeric):
return float(value)
- if issubclass(self.device.sensor_value.dpt_class, DPTString):
+ if issubclass(option.dpt, DPTString):
# DPT 16.000 only allows up to 14 Bytes
return str(value)[:14]
except (ValueError, TypeError) as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: Conversion failed: %s',
self.entity_id,
- self.expose_attribute or "state",
+ option.attribute or "state",
value,
err,
)
@@ -175,32 +267,31 @@ class KNXExposeSensor:
return value # type: ignore[no-any-return]
async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None:
- """Handle entity change."""
+ """Handle entity change for all options."""
new_state = event.data["new_state"]
- if (new_value := self._get_expose_value(new_state)) is None:
- return
- old_state = event.data["old_state"]
- # don't use default value for comparison on first state change (old_state is None)
- old_value = self._get_expose_value(old_state) if old_state is not None else None
- # don't send same value sequentially
- if new_value != old_value:
- await self._async_set_knx_value(new_value)
+ async with TaskGroup() as tg:
+ for option, xknx_expose in self._exposures:
+ expose_value = self._get_expose_value(new_state, option)
+ if expose_value is None:
+ continue
+ tg.create_task(self._async_set_knx_value(xknx_expose, expose_value))
- async def _async_set_knx_value(self, value: StateType) -> None:
+ async def _async_set_knx_value(
+ self, xknx_expose: ExposeSensor, value: StateType
+ ) -> None:
"""Set new value on xknx ExposeSensor."""
try:
- await self.device.set(value)
+ await xknx_expose.set(value, skip_unchanged=True)
except ConversionError as err:
_LOGGER.warning(
- 'Could not expose %s %s value "%s" to KNX: %s',
- self.entity_id,
- self.expose_attribute or "state",
+ 'Could not expose %s value "%s" to KNX: %s',
+ xknx_expose.name,
value,
err,
)
-class KNXExposeTime:
+class KnxExposeTime:
"""Object to Expose Time/Date object to KNX bus."""
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
@@ -222,6 +313,11 @@ class KNXExposeTime:
group_address=config[KNX_ADDRESS],
)
+ @property
+ def name(self) -> str:
+ """Return name of the time expose object."""
+ return f"expose_{self.device.name}"
+
@callback
def async_register(self) -> None:
"""Register listener."""
diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py
index 42c14eae2a8..33e08badf51 100644
--- a/homeassistant/components/knx/knx_module.py
+++ b/homeassistant/components/knx/knx_module.py
@@ -54,7 +54,7 @@ from .const import (
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
-from .expose import KNXExposeSensor, KNXExposeTime
+from .expose import KnxExposeEntity, KnxExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
@@ -73,8 +73,8 @@ class KNXModule:
self.hass = hass
self.config_yaml = config
self.connected = False
- self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
- self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
+ self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
+ self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
self.entry = entry
self.project = KNXProject(hass=hass, entry=entry)
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 5f5862d9219..6e4b25e3b8e 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -11,9 +11,9 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
- "xknx==3.13.0",
+ "xknx==3.14.0",
"xknxproject==3.8.2",
- "knx-frontend==2025.12.30.151231"
+ "knx-frontend==2026.1.15.112308"
],
"single_config_entry": true
}
diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py
index ebb01e0ef28..0e6798e1584 100644
--- a/homeassistant/components/knx/services.py
+++ b/homeassistant/components/knx/services.py
@@ -193,7 +193,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
" for '%s' - %s"
),
group_address,
- replaced_exposure.device.name,
+ replaced_exposure.name,
)
replaced_exposure.async_remove()
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
@@ -201,7 +201,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
_LOGGER.debug(
"Service exposure_register registered exposure for '%s' - %s",
group_address,
- exposure.device.name,
+ exposure.name,
)
diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py
index 8360f74ce24..49e326334b9 100644
--- a/homeassistant/components/kodi/notify.py
+++ b/homeassistant/components/kodi/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import aiohttp
import jsonrpc_async
@@ -93,7 +94,7 @@ class KodiNotificationService(BaseNotificationService):
self._server = jsonrpc_async.Server(self._url, **kwargs)
- async def async_send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Kodi."""
try:
data = kwargs.get(ATTR_DATA) or {}
diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json
index 7bdbb7f703c..af97ddd037b 100644
--- a/homeassistant/components/kostal_plenticore/manifest.json
+++ b/homeassistant/components/kostal_plenticore/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["kostal"],
- "requirements": ["pykoplenti==1.3.0"]
+ "requirements": ["pykoplenti==1.5.0"]
}
diff --git a/homeassistant/components/labs/__init__.py b/homeassistant/components/labs/__init__.py
index 73bee604450..485d7f8c878 100644
--- a/homeassistant/components/labs/__init__.py
+++ b/homeassistant/components/labs/__init__.py
@@ -18,7 +18,11 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
-from .helpers import async_is_preview_feature_enabled, async_listen
+from .helpers import (
+ async_is_preview_feature_enabled,
+ async_listen,
+ async_update_preview_feature,
+)
from .models import (
EventLabsUpdatedData,
LabPreviewFeature,
@@ -37,6 +41,7 @@ __all__ = [
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
"async_listen",
+ "async_update_preview_feature",
]
diff --git a/homeassistant/components/labs/helpers.py b/homeassistant/components/labs/helpers.py
index 3b9ce94b4ad..85430724bef 100644
--- a/homeassistant/components/labs/helpers.py
+++ b/homeassistant/components/labs/helpers.py
@@ -61,3 +61,32 @@ def async_listen(
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
+
+
+async def async_update_preview_feature(
+ hass: HomeAssistant,
+ domain: str,
+ preview_feature: str,
+ enabled: bool,
+) -> None:
+ """Update a lab preview feature state."""
+ labs_data = hass.data[LABS_DATA]
+
+ preview_feature_id = f"{domain}.{preview_feature}"
+
+ if preview_feature_id not in labs_data.preview_features:
+ raise ValueError(f"Preview feature {preview_feature_id} not found")
+
+ if enabled:
+ labs_data.data.preview_feature_status.add((domain, preview_feature))
+ else:
+ labs_data.data.preview_feature_status.discard((domain, preview_feature))
+
+ await labs_data.store.async_save(labs_data.data.to_store_format())
+
+ event_data: EventLabsUpdatedData = {
+ "domain": domain,
+ "preview_feature": preview_feature,
+ "enabled": enabled,
+ }
+ hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
diff --git a/homeassistant/components/labs/websocket_api.py b/homeassistant/components/labs/websocket_api.py
index bccfe0c53de..f1f744f8a47 100644
--- a/homeassistant/components/labs/websocket_api.py
+++ b/homeassistant/components/labs/websocket_api.py
@@ -8,12 +8,14 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.backup import async_get_manager
-from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import HomeAssistant, callback
from .const import LABS_DATA
-from .helpers import async_is_preview_feature_enabled, async_listen
-from .models import EventLabsUpdatedData
+from .helpers import (
+ async_is_preview_feature_enabled,
+ async_listen,
+ async_update_preview_feature,
+)
@callback
@@ -95,19 +97,7 @@ async def websocket_update_preview_feature(
)
return
- if enabled:
- labs_data.data.preview_feature_status.add((domain, preview_feature))
- else:
- labs_data.data.preview_feature_status.discard((domain, preview_feature))
-
- await labs_data.store.async_save(labs_data.data.to_store_format())
-
- event_data: EventLabsUpdatedData = {
- "domain": domain,
- "preview_feature": preview_feature,
- "enabled": enabled,
- }
- hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
+ await async_update_preview_feature(hass, domain, preview_feature, enabled)
connection.send_result(msg["id"])
diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py
index 61db4deb6a2..2eee3956bba 100644
--- a/homeassistant/components/lamarzocco/number.py
+++ b/homeassistant/components/lamarzocco/number.py
@@ -256,6 +256,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
+ and WidgetType.CM_BREW_BY_WEIGHT_DOSES
+ in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
@@ -289,6 +291,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
+ and WidgetType.CM_BREW_BY_WEIGHT_DOSES
+ in coordinator.device.dashboard.config
),
),
)
diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py
index d7662b6f50d..921f6a8d50d 100644
--- a/homeassistant/components/lamarzocco/select.py
+++ b/homeassistant/components/lamarzocco/select.py
@@ -149,6 +149,8 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
+ and WidgetType.CM_BREW_BY_WEIGHT_DOSES
+ in coordinator.device.dashboard.config
),
),
)
diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py
index 983a5e7b32a..4b5f249a2f1 100644
--- a/homeassistant/components/lannouncer/notify.py
+++ b/homeassistant/components/lannouncer/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import socket
+from typing import Any
from urllib.parse import urlencode
import voluptuous as vol
@@ -73,7 +74,7 @@ class LannouncerNotificationService(BaseNotificationService):
self._host = host
self._port = port
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Lannouncer."""
data = kwargs.get(ATTR_DATA)
if data is not None and ATTR_METHOD in data:
diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json
index 501daf20539..0ae72cbee9d 100644
--- a/homeassistant/components/lawn_mower/strings.json
+++ b/homeassistant/components/lawn_mower/strings.json
@@ -41,7 +41,7 @@
"title": "Lawn mower",
"triggers": {
"docked": {
- "description": "Triggers after one or more lawn mowers return to dock.",
+ "description": "Triggers after one or more lawn mowers have returned to dock.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
diff --git a/homeassistant/components/light/condition.py b/homeassistant/components/light/condition.py
index 139f9e71ebc..59fcd10c831 100644
--- a/homeassistant/components/light/condition.py
+++ b/homeassistant/components/light/condition.py
@@ -1,127 +1,14 @@
"""Provides conditions for lights."""
-from collections.abc import Callable
-from typing import TYPE_CHECKING, Any, Final, Unpack, override
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_OPTIONS, CONF_TARGET, STATE_OFF, STATE_ON
-from homeassistant.core import HomeAssistant, split_entity_id
-from homeassistant.helpers import config_validation as cv, target
-from homeassistant.helpers.condition import (
- Condition,
- ConditionChecker,
- ConditionCheckParams,
- ConditionConfig,
-)
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
-ATTR_BEHAVIOR: Final = "behavior"
-BEHAVIOR_ANY: Final = "any"
-BEHAVIOR_ALL: Final = "all"
-
-
-STATE_CONDITION_VALID_STATES: Final = [STATE_ON, STATE_OFF]
-STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
- vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
- [BEHAVIOR_ANY, BEHAVIOR_ALL]
- ),
-}
-STATE_CONDITION_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
- vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
- }
-)
-
-
-class StateConditionBase(Condition):
- """State condition."""
-
- @override
- @classmethod
- async def async_validate_config(
- cls, hass: HomeAssistant, config: ConfigType
- ) -> ConfigType:
- """Validate config."""
- return STATE_CONDITION_SCHEMA(config) # type: ignore[no-any-return]
-
- def __init__(
- self, hass: HomeAssistant, config: ConditionConfig, state: str
- ) -> None:
- """Initialize condition."""
- super().__init__(hass, config)
- if TYPE_CHECKING:
- assert config.target
- assert config.options
- self._target = config.target
- self._behavior = config.options[ATTR_BEHAVIOR]
- self._state = state
-
- @override
- async def async_get_checker(self) -> ConditionChecker:
- """Get the condition checker."""
-
- def check_any_match_state(states: list[str]) -> bool:
- """Test if any entity match the state."""
- return any(state == self._state for state in states)
-
- def check_all_match_state(states: list[str]) -> bool:
- """Test if all entities match the state."""
- return all(state == self._state for state in states)
-
- matcher: Callable[[list[str]], bool]
- if self._behavior == BEHAVIOR_ANY:
- matcher = check_any_match_state
- elif self._behavior == BEHAVIOR_ALL:
- matcher = check_all_match_state
-
- def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool:
- """Test state condition."""
- target_selection = target.TargetSelection(self._target)
- targeted_entities = target.async_extract_referenced_entity_ids(
- self._hass, target_selection, expand_group=False
- )
- referenced_entity_ids = targeted_entities.referenced.union(
- targeted_entities.indirectly_referenced
- )
- light_entity_ids = {
- entity_id
- for entity_id in referenced_entity_ids
- if split_entity_id(entity_id)[0] == DOMAIN
- }
- light_entity_states = [
- state.state
- for entity_id in light_entity_ids
- if (state := self._hass.states.get(entity_id))
- and state.state in STATE_CONDITION_VALID_STATES
- ]
- return matcher(light_entity_states)
-
- return test_state
-
-
-class IsOnCondition(StateConditionBase):
- """Is on condition."""
-
- def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
- """Initialize condition."""
- super().__init__(hass, config, STATE_ON)
-
-
-class IsOffCondition(StateConditionBase):
- """Is off condition."""
-
- def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
- """Initialize condition."""
- super().__init__(hass, config, STATE_OFF)
-
-
CONDITIONS: dict[str, type[Condition]] = {
- "is_off": IsOffCondition,
- "is_on": IsOnCondition,
+ "is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
+ "is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
diff --git a/homeassistant/components/light/conditions.yaml b/homeassistant/components/light/conditions.yaml
index cfcb4748836..a35a581ffc1 100644
--- a/homeassistant/components/light/conditions.yaml
+++ b/homeassistant/components/light/conditions.yaml
@@ -1,18 +1,4 @@
-is_off:
- target:
- entity:
- domain: light
- fields:
- behavior:
- required: true
- default: any
- selector:
- select:
- translation_key: condition_behavior
- options:
- - all
- - any
-is_on:
+.condition_common: &condition_common
target:
entity:
domain: light
@@ -26,3 +12,6 @@ is_on:
options:
- all
- any
+
+is_off: *condition_common
+is_on: *condition_common
diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json
index 73807d09deb..11e5c9a5309 100644
--- a/homeassistant/components/light/strings.json
+++ b/homeassistant/components/light/strings.json
@@ -42,7 +42,7 @@
},
"conditions": {
"is_off": {
- "description": "Test if a light is off.",
+ "description": "Tests if one or more lights are off.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -52,7 +52,7 @@
"name": "If a light is off"
},
"is_on": {
- "description": "Test if a light is on.",
+ "description": "Tests if one or more lights are on.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py
index 5a3550fc58b..2e087b00397 100644
--- a/homeassistant/components/light/trigger.py
+++ b/homeassistant/components/light/trigger.py
@@ -1,24 +1,47 @@
"""Provides triggers for lights."""
+from typing import Any
+
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
+ EntityNumericalStateAttributeChangedTriggerBase,
+ EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
- make_entity_numerical_state_attribute_changed_trigger,
- make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
from . import ATTR_BRIGHTNESS
from .const import DOMAIN
+
+def _convert_uint8_to_percentage(value: Any) -> float:
+ """Convert a uint8 value (0-255) to a percentage (0-100)."""
+ return (float(value) / 255.0) * 100.0
+
+
+class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
+ """Trigger for brightness changed."""
+
+ _domain = DOMAIN
+ _attribute = ATTR_BRIGHTNESS
+
+ _converter = staticmethod(_convert_uint8_to_percentage)
+
+
+class BrightnessCrossedThresholdTrigger(
+ EntityNumericalStateAttributeCrossedThresholdTriggerBase
+):
+ """Trigger for brightness crossed threshold."""
+
+ _domain = DOMAIN
+ _attribute = ATTR_BRIGHTNESS
+ _converter = staticmethod(_convert_uint8_to_percentage)
+
+
TRIGGERS: dict[str, type[Trigger]] = {
- "brightness_changed": make_entity_numerical_state_attribute_changed_trigger(
- DOMAIN, ATTR_BRIGHTNESS
- ),
- "brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
- DOMAIN, ATTR_BRIGHTNESS
- ),
+ "brightness_changed": BrightnessChangedTrigger,
+ "brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}
diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml
index 75843ea1a53..e55026ced87 100644
--- a/homeassistant/components/light/triggers.yaml
+++ b/homeassistant/components/light/triggers.yaml
@@ -22,7 +22,10 @@
number:
selector:
number:
+ max: 100
+ min: 0
mode: box
+ unit_of_measurement: "%"
entity:
selector:
entity:
diff --git a/homeassistant/components/llamalab_automate/notify.py b/homeassistant/components/llamalab_automate/notify.py
index da13267aec3..94693d3faa0 100644
--- a/homeassistant/components/llamalab_automate/notify.py
+++ b/homeassistant/components/llamalab_automate/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
+from typing import Any
import requests
import voluptuous as vol
@@ -56,7 +57,7 @@ class AutomateNotificationService(BaseNotificationService):
self._recipient = recipient
self._device = device
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
# Extract params from data dict
diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py
index a4d34fcb2d6..b3c7535b9b7 100644
--- a/homeassistant/components/london_air/sensor.py
+++ b/homeassistant/components/london_air/sensor.py
@@ -27,6 +27,7 @@ SCAN_INTERVAL = timedelta(minutes=30)
AUTHORITIES = [
"Barking and Dagenham",
+ "Barnet",
"Bexley",
"Brent",
"Bromley",
@@ -49,11 +50,13 @@ AUTHORITIES = [
"Lambeth",
"Lewisham",
"Merton",
+ "Newham",
"Redbridge",
"Richmond",
"Southwark",
"Sutton",
"Tower Hamlets",
+ "Waltham Forest",
"Wandsworth",
"Westminster",
]
diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json
index 1b9b8ddcbeb..15cf41ef98c 100644
--- a/homeassistant/components/london_underground/manifest.json
+++ b/homeassistant/components/london_underground/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
- "quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py
index b839e184810..daf5eb904ab 100644
--- a/homeassistant/components/mailgun/notify.py
+++ b/homeassistant/components/mailgun/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from pymailgunner import (
Client,
@@ -91,7 +92,7 @@ class MailgunNotificationService(BaseNotificationService):
return False
return True
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a mail to the recipient."""
subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
index 531b88ac38a..8e4910d937a 100644
--- a/homeassistant/components/mastodon/__init__.py
+++ b/homeassistant/components/mastodon/__init__.py
@@ -28,7 +28,7 @@ from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData
from .services import async_setup_services
from .utils import construct_mastodon_username, create_mastodon_client
-PLATFORMS: list[Platform] = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
diff --git a/homeassistant/components/mastodon/binary_sensor.py b/homeassistant/components/mastodon/binary_sensor.py
new file mode 100644
index 00000000000..42400c8b238
--- /dev/null
+++ b/homeassistant/components/mastodon/binary_sensor.py
@@ -0,0 +1,128 @@
+"""Binary sensor platform for the Mastodon integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import StrEnum
+
+from mastodon.Mastodon import Account
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import MastodonConfigEntry
+from .entity import MastodonEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+class MastodonBinarySensor(StrEnum):
+ """Mastodon binary sensors."""
+
+ BOT = "bot"
+ SUSPENDED = "suspended"
+ DISCOVERABLE = "discoverable"
+ LOCKED = "locked"
+ INDEXABLE = "indexable"
+ LIMITED = "limited"
+ MEMORIAL = "memorial"
+ MOVED = "moved"
+
+
+@dataclass(frozen=True, kw_only=True)
+class MastodonBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Mastodon binary sensor description."""
+
+ is_on_fn: Callable[[Account], bool | None]
+
+
+ENTITY_DESCRIPTIONS: tuple[MastodonBinarySensorEntityDescription, ...] = (
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.BOT,
+ translation_key=MastodonBinarySensor.BOT,
+ is_on_fn=lambda account: account.bot,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.DISCOVERABLE,
+ translation_key=MastodonBinarySensor.DISCOVERABLE,
+ is_on_fn=lambda account: account.discoverable,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.LOCKED,
+ translation_key=MastodonBinarySensor.LOCKED,
+ is_on_fn=lambda account: account.locked,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.MOVED,
+ translation_key=MastodonBinarySensor.MOVED,
+ is_on_fn=lambda account: account.moved is not None,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.INDEXABLE,
+ translation_key=MastodonBinarySensor.INDEXABLE,
+ is_on_fn=lambda account: account.indexable,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.LIMITED,
+ translation_key=MastodonBinarySensor.LIMITED,
+ is_on_fn=lambda account: account.limited is True,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.MEMORIAL,
+ translation_key=MastodonBinarySensor.MEMORIAL,
+ is_on_fn=lambda account: account.memorial is True,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ MastodonBinarySensorEntityDescription(
+ key=MastodonBinarySensor.SUSPENDED,
+ translation_key=MastodonBinarySensor.SUSPENDED,
+ is_on_fn=lambda account: account.suspended is True,
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MastodonConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the binary sensor platform."""
+ coordinator = entry.runtime_data.coordinator
+
+ async_add_entities(
+ MastodonBinarySensorEntity(
+ coordinator=coordinator,
+ entity_description=entity_description,
+ data=entry,
+ )
+ for entity_description in ENTITY_DESCRIPTIONS
+ )
+
+
+class MastodonBinarySensorEntity(MastodonEntity, BinarySensorEntity):
+ """Mastodon binary sensor entity."""
+
+ entity_description: MastodonBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json
index e7272c2b6f8..2ea8b34788f 100644
--- a/homeassistant/components/mastodon/icons.json
+++ b/homeassistant/components/mastodon/icons.json
@@ -1,5 +1,18 @@
{
"entity": {
+ "binary_sensor": {
+ "bot": { "default": "mdi:robot" },
+ "discoverable": { "default": "mdi:magnify-scan" },
+ "indexable": { "default": "mdi:search-web" },
+ "limited": { "default": "mdi:account-cancel" },
+ "locked": {
+ "default": "mdi:account-lock",
+ "state": { "off": "mdi:account-lock-open" }
+ },
+ "memorial": { "default": "mdi:candle" },
+ "moved": { "default": "mdi:truck-delivery" },
+ "suspended": { "default": "mdi:account-off" }
+ },
"sensor": {
"followers": {
"default": "mdi:account-multiple"
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index 93161a8129d..5c42cdefcf3 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -26,6 +26,16 @@
}
},
"entity": {
+ "binary_sensor": {
+ "bot": { "name": "Bot" },
+ "discoverable": { "name": "Discoverable" },
+ "indexable": { "name": "Indexable" },
+ "limited": { "name": "Limited" },
+ "locked": { "name": "Locked" },
+ "memorial": { "name": "Memorial" },
+ "moved": { "name": "Moved" },
+ "suspended": { "name": "Suspended" }
+ },
"sensor": {
"followers": {
"name": "Followers",
diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py
index a3148f3719f..58c6edff070 100644
--- a/homeassistant/components/matter/binary_sensor.py
+++ b/homeassistant/components/matter/binary_sensor.py
@@ -489,6 +489,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="WindowCoveringConfigStatusOperational",
+ translation_key="config_status_operational",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# unset Operational bit from ConfigStatus bitmap means problem
@@ -528,7 +529,10 @@ DISCOVERY_SCHEMAS = [
),
),
entity_class=MatterBinarySensor,
- required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,),
+ required_attributes=(
+ clusters.Thermostat.Attributes.RemoteSensing,
+ clusters.Thermostat.Attributes.OutdoorTemperature,
+ ),
allow_multi=True,
),
MatterDiscoverySchema(
diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py
index a20e6b3b42a..db13c9faebb 100644
--- a/homeassistant/components/matter/number.py
+++ b/homeassistant/components/matter/number.py
@@ -66,8 +66,9 @@ class MatterRangeNumberEntityDescription(
format_max_value: Callable[[float], float] = lambda x: x
# command: a custom callback to create the command to send to the device
- # the callback's argument will be the index of the selected list value
- command: Callable[[int], ClusterCommand]
+ # the callback's argument will be the converted device value from ha_to_device
+ # if omitted the command will just be a write_attribute command to the primary attribute
+ command: Callable[[int], ClusterCommand] | None = None
class MatterNumber(MatterEntity, NumberEntity):
@@ -99,9 +100,15 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_device(value)
- # custom command defined to set the new value
- await self.send_device_command(
- self.entity_description.command(send_value),
+ if self.entity_description.command:
+ # custom command defined to set the new value
+ await self.send_device_command(
+ self.entity_description.command(send_value),
+ )
+ return
+ # regular write attribute to set the new value
+ await self.write_attribute(
+ value=send_value,
)
@callback
@@ -253,6 +260,30 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,),
),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterRangeNumberEntityDescription(
+ key="ThermostatOccupiedSetback",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="occupied_setback",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_to_ha=lambda x: None if x is None else x / 10,
+ ha_to_device=lambda x: round(x * 10),
+ format_min_value=lambda x: x / 10,
+ format_max_value=lambda x: x / 10,
+ min_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMin,
+ max_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMax,
+ native_step=0.5,
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterRangeNumber,
+ required_attributes=(
+ clusters.Thermostat.Attributes.OccupiedSetback,
+ clusters.Thermostat.Attributes.OccupiedSetbackMin,
+ clusters.Thermostat.Attributes.OccupiedSetbackMax,
+ ),
+ featuremap_contains=(clusters.Thermostat.Bitmaps.Feature.kSetback),
+ ),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py
index cc7041965fe..ff72dcd58b5 100644
--- a/homeassistant/components/matter/select.py
+++ b/homeassistant/components/matter/select.py
@@ -642,6 +642,7 @@ DISCOVERY_SCHEMAS = [
list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes,
device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get,
ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get,
+ entity_category=EntityCategory.CONFIG,
),
entity_class=MatterDoorLockOperatingModeSelectEntity,
required_attributes=(
diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py
index 0832675744e..3077ff10cc6 100644
--- a/homeassistant/components/matter/sensor.py
+++ b/homeassistant/components/matter/sensor.py
@@ -442,6 +442,9 @@ DISCOVERY_SCHEMAS = [
key="PowerSourceBatVoltage",
translation_key="battery_voltage",
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ # Battery voltages are low-voltage diagnostics; use 2 decimals in volts
+ # to provide finer granularity than mains-level voltage sensors.
+ suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index 9fa3425c4e4..abaaf46ef42 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -56,6 +56,9 @@
"boost_state": {
"name": "Boost state"
},
+ "config_status_operational": {
+ "name": "Configuration status"
+ },
"dishwasher_alarm_inflow": {
"name": "Inflow alarm"
},
@@ -217,6 +220,9 @@
"led_indicator_intensity_on": {
"name": "LED on intensity"
},
+ "occupied_setback": {
+ "name": "Occupied setback"
+ },
"off_transition_time": {
"name": "Off transition time"
},
diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py
index 65b1795023f..c20a855d656 100644
--- a/homeassistant/components/maxcube/climate.py
+++ b/homeassistant/components/maxcube/climate.py
@@ -192,7 +192,7 @@ class MaxCubeClimate(ClimateEntity):
self._set_target(None, temp)
@property
- def preset_mode(self):
+ def preset_mode(self) -> str:
"""Return the current preset mode."""
if self._device.mode == MAX_DEVICE_MODE_MANUAL:
if self._device.target_temperature == self._device.comfort_temperature:
diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py
index a88347894f5..f0f294e45cb 100644
--- a/homeassistant/components/mealie/config_flow.py
+++ b/homeassistant/components/mealie/config_flow.py
@@ -50,7 +50,7 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Check connection to the Mealie API."""
assert self.host is not None
- if "/hassio/ingress/" in self.host:
+ if "/app/" in self.host:
return {"base": "ingress_url"}, None
client = MealieClient(
diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json
index 5e090a6af73..21fe1b11197 100644
--- a/homeassistant/components/mealie/manifest.json
+++ b/homeassistant/components/mealie/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
- "requirements": ["aiomealie==1.1.1"]
+ "requirements": ["aiomealie==1.2.0"]
}
diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py
index d78807106c1..34ac5aea1cf 100644
--- a/homeassistant/components/melcloud/__init__.py
+++ b/homeassistant/components/melcloud/__init__.py
@@ -4,45 +4,70 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
-import logging
-from typing import Any
+from http import HTTPStatus
from aiohttp import ClientConnectionError, ClientResponseError
-from pymelcloud import Device, get_devices
-from pymelcloud.atw_device import Zone
+from pymelcloud import get_devices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.util import Throttle
+from homeassistant.helpers.update_coordinator import UpdateFailed
-from .const import DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
-type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]]
-
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
"""Establish connection with MELCloud."""
- conf = entry.data
try:
- mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN])
+ async with asyncio.timeout(10):
+ all_devices = await get_devices(
+ token=entry.data[CONF_TOKEN],
+ session=async_get_clientsession(hass),
+ conf_update_interval=timedelta(minutes=30),
+ device_set_debounce=timedelta(seconds=2),
+ )
except ClientResponseError as ex:
- if isinstance(ex, ClientResponseError) and ex.code == 401:
+ if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise ConfigEntryAuthFailed from ex
- raise ConfigEntryNotReady from ex
+ if ex.status == HTTPStatus.TOO_MANY_REQUESTS:
+ raise UpdateFailed(
+ "MELCloud rate limit exceeded. Your account may be temporarily blocked"
+ ) from ex
+ raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
except (TimeoutError, ClientConnectionError) as ex:
- raise ConfigEntryNotReady from ex
+ raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
- entry.runtime_data = mel_devices
+ # Create per-device coordinators
+ coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {}
+ device_registry = dr.async_get(hass)
+ for device_type, devices in all_devices.items():
+ # Build coordinators for this device_type
+ coordinators[device_type] = [
+ MelCloudDeviceUpdateCoordinator(hass, device, entry) for device in devices
+ ]
+
+ # Perform initial refreshes concurrently
+ await asyncio.gather(
+ *(
+ coordinator.async_config_entry_first_refresh()
+ for coordinator in coordinators[device_type]
+ )
+ )
+
+ # Register parent devices so zone entities can reference via_device
+ for coordinator in coordinators[device_type]:
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **coordinator.device_info,
+ )
+
+ entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -50,90 +75,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
-
-
-class MelCloudDevice:
- """MELCloud Device instance."""
-
- def __init__(self, device: Device) -> None:
- """Construct a device wrapper."""
- self.device = device
- self.name = device.name
- self._available = True
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- async def async_update(self, **kwargs):
- """Pull the latest data from MELCloud."""
- try:
- await self.device.update()
- self._available = True
- except ClientConnectionError:
- _LOGGER.warning("Connection failed for %s", self.name)
- self._available = False
-
- async def async_set(self, properties: dict[str, Any]):
- """Write state changes to the MELCloud API."""
- try:
- await self.device.set(properties)
- self._available = True
- except ClientConnectionError:
- _LOGGER.warning("Connection failed for %s", self.name)
- self._available = False
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._available
-
- @property
- def device_id(self):
- """Return device ID."""
- return self.device.device_id
-
- @property
- def building_id(self):
- """Return building ID of the device."""
- return self.device.building_id
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return a device description for device registry."""
- model = None
- if (unit_infos := self.device.units) is not None:
- model = ", ".join([x["model"] for x in unit_infos if x["model"]])
- return DeviceInfo(
- connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
- identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
- manufacturer="Mitsubishi Electric",
- model=model,
- name=self.name,
- )
-
- def zone_device_info(self, zone: Zone) -> DeviceInfo:
- """Return a zone device description for device registry."""
- dev = self.device
- return DeviceInfo(
- identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
- manufacturer="Mitsubishi Electric",
- model="ATW zone device",
- name=f"{self.name} {zone.name}",
- via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
- )
-
-
-async def mel_devices_setup(
- hass: HomeAssistant, token: str
-) -> dict[str, list[MelCloudDevice]]:
- """Query connected devices from MELCloud."""
- session = async_get_clientsession(hass)
- async with asyncio.timeout(10):
- all_devices = await get_devices(
- token,
- session,
- conf_update_interval=timedelta(minutes=30),
- device_set_debounce=timedelta(seconds=2),
- )
- wrapped_devices: dict[str, list[MelCloudDevice]] = {}
- for device_type, devices in all_devices.items():
- wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices]
- return wrapped_devices
diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py
index 47a96d03f06..488268a3295 100644
--- a/homeassistant/components/melcloud/climate.py
+++ b/homeassistant/components/melcloud/climate.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import timedelta
from typing import Any, cast
from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
@@ -29,7 +28,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import MelCloudConfigEntry, MelCloudDevice
from .const import (
ATTR_STATUS,
ATTR_VANE_HORIZONTAL,
@@ -40,9 +38,8 @@ from .const import (
SERVICE_SET_VANE_HORIZONTAL,
SERVICE_SET_VANE_VERTICAL,
)
-
-SCAN_INTERVAL = timedelta(seconds=60)
-
+from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
+from .entity import MelCloudEntity
ATA_HVAC_MODE_LOOKUP = {
ata.OPERATION_MODE_HEAT: HVACMode.HEAT,
@@ -74,27 +71,24 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = {
async def async_setup_entry(
- hass: HomeAssistant,
+ _hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
- mel_devices = entry.runtime_data
+ coordinators = entry.runtime_data
entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [
- AtaDeviceClimate(mel_device, mel_device.device)
- for mel_device in mel_devices[DEVICE_TYPE_ATA]
+ AtaDeviceClimate(coordinator, coordinator.device)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
]
entities.extend(
[
- AtwDeviceZoneClimate(mel_device, mel_device.device, zone)
- for mel_device in mel_devices[DEVICE_TYPE_ATW]
- for zone in mel_device.device.zones
+ AtwDeviceZoneClimate(coordinator, coordinator.device, zone)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
+ for zone in coordinator.device.zones
]
)
- async_add_entities(
- entities,
- True,
- )
+ async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -109,21 +103,19 @@ async def async_setup_entry(
)
-class MelCloudClimate(ClimateEntity):
+class MelCloudClimate(MelCloudEntity, ClimateEntity):
"""Base climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _attr_has_entity_name = True
_attr_name = None
- def __init__(self, device: MelCloudDevice) -> None:
+ def __init__(
+ self,
+ coordinator: MelCloudDeviceUpdateCoordinator,
+ ) -> None:
"""Initialize the climate."""
- self.api = device
- self._base_device = self.api.device
-
- async def async_update(self) -> None:
- """Update state from MELCloud."""
- await self.api.async_update()
+ super().__init__(coordinator)
+ self._base_device = self.coordinator.device
@property
def target_temperature_step(self) -> float | None:
@@ -142,26 +134,29 @@ class AtaDeviceClimate(MelCloudClimate):
| ClimateEntityFeature.TURN_ON
)
- def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None:
+ def __init__(
+ self,
+ coordinator: MelCloudDeviceUpdateCoordinator,
+ ata_device: AtaDevice,
+ ) -> None:
"""Initialize the climate."""
- super().__init__(device)
+ super().__init__(coordinator)
self._device = ata_device
- self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
- self._attr_device_info = self.api.device_info
+ self._attr_unique_id = (
+ f"{self.coordinator.device.serial}-{self.coordinator.device.mac}"
+ )
+ self._attr_device_info = self.coordinator.device_info
- async def async_added_to_hass(self) -> None:
- """When entity is added to hass."""
- await super().async_added_to_hass()
-
- # We can only check for vane_horizontal once we fetch the device data from the cloud
+ # Add horizontal swing if device supports it
if self._device.vane_horizontal:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions."""
- attr = {}
+ attr: dict[str, Any] = {}
+ attr.update(self.coordinator.extra_attributes)
if vane_horizontal := self._device.vane_horizontal:
attr.update(
@@ -208,7 +203,7 @@ class AtaDeviceClimate(MelCloudClimate):
"""Set new target hvac mode."""
set_dict: dict[str, Any] = {}
self._apply_set_hvac_mode(hvac_mode, set_dict)
- await self._device.set(set_dict)
+ await self.coordinator.async_set(set_dict)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -241,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate):
set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE)
if set_dict:
- await self._device.set(set_dict)
+ await self.coordinator.async_set(set_dict)
@property
def fan_mode(self) -> str | None:
@@ -250,7 +245,7 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- await self._device.set({"fan_speed": fan_mode})
+ await self.coordinator.async_set({"fan_speed": fan_mode})
@property
def fan_modes(self) -> list[str] | None:
@@ -264,7 +259,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid horizontal vane position {position}. Valid positions:"
f" [{self._device.vane_horizontal_positions}]."
)
- await self._device.set({ata.PROPERTY_VANE_HORIZONTAL: position})
+ await self.coordinator.async_set({ata.PROPERTY_VANE_HORIZONTAL: position})
async def async_set_vane_vertical(self, position: str) -> None:
"""Set vertical vane position."""
@@ -273,7 +268,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid vertical vane position {position}. Valid positions:"
f" [{self._device.vane_vertical_positions}]."
)
- await self._device.set({ata.PROPERTY_VANE_VERTICAL: position})
+ await self.coordinator.async_set({ata.PROPERTY_VANE_VERTICAL: position})
@property
def swing_mode(self) -> str | None:
@@ -305,11 +300,11 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_turn_on(self) -> None:
"""Turn the entity on."""
- await self._device.set({"power": True})
+ await self.coordinator.async_set({"power": True})
async def async_turn_off(self) -> None:
"""Turn the entity off."""
- await self._device.set({"power": False})
+ await self.coordinator.async_set({"power": False})
@property
def min_temp(self) -> float:
@@ -338,15 +333,18 @@ class AtwDeviceZoneClimate(MelCloudClimate):
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
def __init__(
- self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone
+ self,
+ coordinator: MelCloudDeviceUpdateCoordinator,
+ atw_device: AtwDevice,
+ atw_zone: Zone,
) -> None:
"""Initialize the climate."""
- super().__init__(device)
+ super().__init__(coordinator)
self._device = atw_device
self._zone = atw_zone
- self._attr_unique_id = f"{self.api.device.serial}-{atw_zone.zone_index}"
- self._attr_device_info = self.api.zone_device_info(atw_zone)
+ self._attr_unique_id = f"{self.coordinator.device.serial}-{atw_zone.zone_index}"
+ self._attr_device_info = self.coordinator.zone_device_info(atw_zone)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -360,15 +358,16 @@ class AtwDeviceZoneClimate(MelCloudClimate):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
- mode = self._zone.operation_mode
- if not self._device.power or mode is None:
+ # Use zone status (heat/cool/idle) not operation_mode (heat-thermostat/etc.)
+ status = self._zone.status
+ if not self._device.power or status is None:
return HVACMode.OFF
- return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVACMode.OFF)
+ return ATW_ZONE_HVAC_MODE_LOOKUP.get(status, HVACMode.OFF)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF:
- await self._device.set({"power": False})
+ await self.coordinator.async_set({"power": False})
return
operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
@@ -381,7 +380,7 @@ class AtwDeviceZoneClimate(MelCloudClimate):
props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode}
if self.hvac_mode == HVACMode.OFF:
props["power"] = True
- await self._device.set(props)
+ await self.coordinator.async_set(props)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -410,3 +409,4 @@ class AtwDeviceZoneClimate(MelCloudClimate):
await self._zone.set_target_temperature(
kwargs.get(ATTR_TEMPERATURE, self.target_temperature)
)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py
index d2c9d67f29a..87fe14dce81 100644
--- a/homeassistant/components/melcloud/config_flow.py
+++ b/homeassistant/components/melcloud/config_flow.py
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from http import HTTPStatus
-import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
@@ -18,8 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -37,8 +34,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_client(
self,
username: str,
- *,
- password: str | None = None,
+ password: str,
token: str | None = None,
) -> ConfigFlowResult:
"""Create client."""
@@ -46,13 +42,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
async with asyncio.timeout(10):
if (acquired_token := token) is None:
acquired_token = await pymelcloud.login(
- username,
- password,
- async_get_clientsession(self.hass),
+ email=username,
+ password=password,
+ session=async_get_clientsession(self.hass),
)
await pymelcloud.get_devices(
- acquired_token,
- async_get_clientsession(self.hass),
+ token=acquired_token,
+ session=async_get_clientsession(self.hass),
)
except ClientResponseError as err:
if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
@@ -60,6 +56,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="cannot_connect")
except (TimeoutError, ClientError):
return self.async_abort(reason="cannot_connect")
+ except AttributeError:
+ # python-melcloud library bug: login() raises AttributeError on invalid
+ # credentials when API response doesn't contain expected "LoginData" key
+ return self.async_abort(reason="invalid_auth")
return await self._create_entry(username, acquired_token)
@@ -74,8 +74,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
),
)
- username = user_input[CONF_USERNAME]
- return await self._create_client(username, password=user_input[CONF_PASSWORD])
+ return await self._create_client(
+ username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD]
+ )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -114,9 +115,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(10):
acquired_token = await pymelcloud.login(
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- async_get_clientsession(self.hass),
+ email=user_input[CONF_USERNAME],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
)
except (ClientResponseError, AttributeError) as err:
if (
@@ -130,10 +131,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
- except (
- TimeoutError,
- ClientError,
- ):
+ except (TimeoutError, ClientError):
errors["base"] = "cannot_connect"
return acquired_token, errors
@@ -151,9 +149,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(10):
acquired_token = await pymelcloud.login(
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- async_get_clientsession(self.hass),
+ email=user_input[CONF_USERNAME],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
)
except (ClientResponseError, AttributeError) as err:
if (
diff --git a/homeassistant/components/melcloud/coordinator.py b/homeassistant/components/melcloud/coordinator.py
new file mode 100644
index 00000000000..3b4c6f57f5b
--- /dev/null
+++ b/homeassistant/components/melcloud/coordinator.py
@@ -0,0 +1,193 @@
+"""DataUpdateCoordinator for the MELCloud integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Any
+
+from aiohttp import ClientConnectionError, ClientResponseError
+from pymelcloud import Device
+from pymelcloud.atw_device import Zone
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.debounce import Debouncer
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+# Delay before refreshing after a state change to allow device to process
+# and avoid race conditions with rapid sequential changes
+REQUEST_REFRESH_DELAY = 1.5
+
+# Default update interval in minutes (matches upstream Throttle value)
+DEFAULT_UPDATE_INTERVAL = 15
+
+# Retry interval in seconds for transient failures
+RETRY_INTERVAL_SECONDS = 30
+
+# Number of consecutive failures before marking device unavailable
+MAX_CONSECUTIVE_FAILURES = 3
+
+
+class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Per-device coordinator for MELCloud data updates."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ device: Device,
+ config_entry: ConfigEntry,
+ ) -> None:
+ """Initialize the per-device coordinator."""
+ self.device = device
+ self.device_available = True
+ self._consecutive_failures = 0
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=f"{DOMAIN}_{device.name}",
+ update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
+ always_update=True,
+ request_refresh_debouncer=Debouncer(
+ hass,
+ _LOGGER,
+ cooldown=REQUEST_REFRESH_DELAY,
+ immediate=False,
+ ),
+ )
+
+ @property
+ def extra_attributes(self) -> dict[str, Any]:
+ """Return extra device attributes."""
+ data: dict[str, Any] = {
+ "device_id": self.device.device_id,
+ "serial": self.device.serial,
+ "mac": self.device.mac,
+ }
+ if (unit_infos := self.device.units) is not None:
+ for i, unit in enumerate(unit_infos[:2]):
+ data[f"unit_{i}_model"] = unit.get("model")
+ data[f"unit_{i}_serial"] = unit.get("serial")
+ return data
+
+ @property
+ def device_id(self) -> str:
+ """Return device ID."""
+ return self.device.device_id
+
+ @property
+ def building_id(self) -> str:
+ """Return building ID of the device."""
+ return self.device.building_id
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return a device description for device registry."""
+ model = None
+ if (unit_infos := self.device.units) is not None:
+ model = ", ".join([x["model"] for x in unit_infos if x["model"]])
+ return DeviceInfo(
+ connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
+ identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
+ manufacturer="Mitsubishi Electric",
+ model=model,
+ name=self.device.name,
+ )
+
+ def zone_device_info(self, zone: Zone) -> DeviceInfo:
+ """Return a zone device description for device registry."""
+ dev = self.device
+ return DeviceInfo(
+ identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
+ manufacturer="Mitsubishi Electric",
+ model="ATW zone device",
+ name=f"{self.device.name} {zone.name}",
+ via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data for this specific device from MELCloud."""
+ try:
+ await self.device.update()
+ # Success - reset failure counter and restore normal interval
+ if self._consecutive_failures > 0:
+ _LOGGER.info(
+ "Connection restored for %s after %d failed attempt(s)",
+ self.device.name,
+ self._consecutive_failures,
+ )
+ self._consecutive_failures = 0
+ self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
+ self.device_available = True
+ except ClientResponseError as ex:
+ if ex.status in (401, 403):
+ raise ConfigEntryAuthFailed from ex
+ if ex.status == 429:
+ _LOGGER.error(
+ "MELCloud rate limit exceeded for %s. Your account may be "
+ "temporarily blocked",
+ self.device.name,
+ )
+ # Rate limit - mark unavailable immediately
+ self.device_available = False
+ raise UpdateFailed(
+ f"Rate limit exceeded for {self.device.name}"
+ ) from ex
+ # Other HTTP errors - use retry logic
+ self._handle_failure(f"Error updating {self.device.name}: {ex}", ex)
+ except ClientConnectionError as ex:
+ self._handle_failure(f"Connection failed for {self.device.name}: {ex}", ex)
+
+ def _handle_failure(self, message: str, exception: Exception | None = None) -> None:
+ """Handle a connection failure with retry logic.
+
+ For transient failures, entities remain available with their last known
+ values for up to MAX_CONSECUTIVE_FAILURES attempts. During retries, the
+ update interval is shortened to RETRY_INTERVAL_SECONDS for faster recovery.
+ After the threshold is reached, entities are marked unavailable.
+ """
+ self._consecutive_failures += 1
+
+ if self._consecutive_failures < MAX_CONSECUTIVE_FAILURES:
+ # Keep entities available with cached data, use shorter retry interval
+ _LOGGER.warning(
+ "%s (attempt %d/%d, retrying in %ds)",
+ message,
+ self._consecutive_failures,
+ MAX_CONSECUTIVE_FAILURES,
+ RETRY_INTERVAL_SECONDS,
+ )
+ self.update_interval = timedelta(seconds=RETRY_INTERVAL_SECONDS)
+ else:
+ # Threshold reached - mark unavailable and restore normal interval
+ _LOGGER.warning(
+ "%s (attempt %d/%d, marking unavailable)",
+ message,
+ self._consecutive_failures,
+ MAX_CONSECUTIVE_FAILURES,
+ )
+ self.device_available = False
+ self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
+ raise UpdateFailed(message) from exception
+
+ async def async_set(self, properties: dict[str, Any]) -> None:
+ """Write state changes to the MELCloud API."""
+ try:
+ await self.device.set(properties)
+ self.device_available = True
+ except ClientConnectionError:
+ _LOGGER.warning("Connection failed for %s", self.device.name)
+ self.device_available = False
+
+ await self.async_request_refresh()
+
+
+type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordinator]]]
diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py
index 4606b7c25e5..c601f886470 100644
--- a/homeassistant/components/melcloud/diagnostics.py
+++ b/homeassistant/components/melcloud/diagnostics.py
@@ -9,7 +9,7 @@ from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from . import MelCloudConfigEntry
+from .coordinator import MelCloudConfigEntry
TO_REDACT = {
CONF_USERNAME,
diff --git a/homeassistant/components/melcloud/entity.py b/homeassistant/components/melcloud/entity.py
new file mode 100644
index 00000000000..b0d9b839481
--- /dev/null
+++ b/homeassistant/components/melcloud/entity.py
@@ -0,0 +1,18 @@
+"""Base entity for MELCloud integration."""
+
+from __future__ import annotations
+
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import MelCloudDeviceUpdateCoordinator
+
+
+class MelCloudEntity(CoordinatorEntity[MelCloudDeviceUpdateCoordinator]):
+ """Base class for MELCloud entities."""
+
+ _attr_has_entity_name = True
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self.coordinator.device_available
diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json
index c68b9cab3c3..b683ee6671a 100644
--- a/homeassistant/components/melcloud/manifest.json
+++ b/homeassistant/components/melcloud/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/melcloud",
"integration_type": "device",
"iot_class": "cloud_polling",
- "loggers": ["pymelcloud"],
+ "loggers": ["melcloud"],
"requirements": ["python-melcloud==0.1.2"]
}
diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py
index 1d36c74f27c..f88150ac6cd 100644
--- a/homeassistant/components/melcloud/sensor.py
+++ b/homeassistant/components/melcloud/sensor.py
@@ -19,7 +19,8 @@ from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import MelCloudConfigEntry, MelCloudDevice
+from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
+from .entity import MelCloudEntity
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -111,70 +112,67 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant,
+ _hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device sensors based on config_entry."""
- mel_devices = entry.runtime_data
+ coordinators = entry.runtime_data
entities: list[MelDeviceSensor] = [
- MelDeviceSensor(mel_device, description)
+ MelDeviceSensor(coordinator, description)
for description in ATA_SENSORS
- for mel_device in mel_devices[DEVICE_TYPE_ATA]
- if description.enabled(mel_device)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
+ if description.enabled(coordinator)
] + [
- MelDeviceSensor(mel_device, description)
+ MelDeviceSensor(coordinator, description)
for description in ATW_SENSORS
- for mel_device in mel_devices[DEVICE_TYPE_ATW]
- if description.enabled(mel_device)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
+ if description.enabled(coordinator)
]
entities.extend(
[
- AtwZoneSensor(mel_device, zone, description)
- for mel_device in mel_devices[DEVICE_TYPE_ATW]
- for zone in mel_device.device.zones
+ AtwZoneSensor(coordinator, zone, description)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
+ for zone in coordinator.device.zones
for description in ATW_ZONE_SENSORS
if description.enabled(zone)
]
)
- async_add_entities(entities, True)
+ async_add_entities(entities)
-class MelDeviceSensor(SensorEntity):
+class MelDeviceSensor(MelCloudEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: MelcloudSensorEntityDescription
- _attr_has_entity_name = True
def __init__(
self,
- api: MelCloudDevice,
+ coordinator: MelCloudDeviceUpdateCoordinator,
description: MelcloudSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
- self._api = api
+ super().__init__(coordinator)
self.entity_description = description
- self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}"
- self._attr_device_info = api.device_info
+ self._attr_unique_id = (
+ f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}"
+ )
+ self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
- return self.entity_description.value_fn(self._api)
-
- async def async_update(self) -> None:
- """Retrieve latest state."""
- await self._api.async_update()
+ return self.entity_description.value_fn(self.coordinator)
class AtwZoneSensor(MelDeviceSensor):
- """Air-to-Air device sensor."""
+ """Air-to-Water zone sensor."""
def __init__(
self,
- api: MelCloudDevice,
+ coordinator: MelCloudDeviceUpdateCoordinator,
zone: Zone,
description: MelcloudSensorEntityDescription,
) -> None:
@@ -184,9 +182,9 @@ class AtwZoneSensor(MelDeviceSensor):
description,
key=f"{description.key}-zone-{zone.zone_index}",
)
- super().__init__(api, description)
+ super().__init__(coordinator, description)
- self._attr_device_info = api.zone_device_info(zone)
+ self._attr_device_info = coordinator.zone_device_info(zone)
self._zone = zone
@property
diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json
index b670530283f..c8a1d14c214 100644
--- a/homeassistant/components/melcloud/strings.json
+++ b/homeassistant/components/melcloud/strings.json
@@ -43,6 +43,9 @@
},
"entity": {
"sensor": {
+ "energy_consumed": {
+ "name": "Energy consumed"
+ },
"flow_temperature": {
"name": "Flow temperature"
},
diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py
index f006df2478e..6b91ef4a353 100644
--- a/homeassistant/components/melcloud/water_heater.py
+++ b/homeassistant/components/melcloud/water_heater.py
@@ -21,27 +21,27 @@ from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import MelCloudConfigEntry, MelCloudDevice
from .const import ATTR_STATUS
+from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
+from .entity import MelCloudEntity
async def async_setup_entry(
- hass: HomeAssistant,
+ _hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
- mel_devices = entry.runtime_data
+ coordinators = entry.runtime_data
async_add_entities(
[
- AtwWaterHeater(mel_device, mel_device.device)
- for mel_device in mel_devices[DEVICE_TYPE_ATW]
- ],
- True,
+ AtwWaterHeater(coordinator, coordinator.device)
+ for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
+ ]
)
-class AtwWaterHeater(WaterHeaterEntity):
+class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
"""Air-to-Water water heater."""
_attr_supported_features = (
@@ -49,27 +49,26 @@ class AtwWaterHeater(WaterHeaterEntity):
| WaterHeaterEntityFeature.ON_OFF
| WaterHeaterEntityFeature.OPERATION_MODE
)
- _attr_has_entity_name = True
_attr_name = None
- def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None:
+ def __init__(
+ self,
+ coordinator: MelCloudDeviceUpdateCoordinator,
+ device: AtwDevice,
+ ) -> None:
"""Initialize water heater device."""
- self._api = api
+ super().__init__(coordinator)
self._device = device
- self._attr_unique_id = api.device.serial
- self._attr_device_info = api.device_info
+ self._attr_unique_id = coordinator.device.serial
+ self._attr_device_info = coordinator.device_info
- async def async_update(self) -> None:
- """Update state from MELCloud."""
- await self._api.async_update()
-
- async def async_turn_on(self, **kwargs: Any) -> None:
+ async def async_turn_on(self, **_kwargs: Any) -> None:
"""Turn the entity on."""
- await self._device.set({PROPERTY_POWER: True})
+ await self.coordinator.async_set({PROPERTY_POWER: True})
- async def async_turn_off(self, **kwargs: Any) -> None:
+ async def async_turn_off(self, **_kwargs: Any) -> None:
"""Turn the entity off."""
- await self._device.set({PROPERTY_POWER: False})
+ await self.coordinator.async_set({PROPERTY_POWER: False})
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -103,7 +102,7 @@ class AtwWaterHeater(WaterHeaterEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- await self._device.set(
+ await self.coordinator.async_set(
{
PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get(
"temperature", self.target_temperature
@@ -113,7 +112,7 @@ class AtwWaterHeater(WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
- await self._device.set({PROPERTY_OPERATION_MODE: operation_mode})
+ await self.coordinator.async_set({PROPERTY_OPERATION_MODE: operation_mode})
@property
def min_temp(self) -> float:
diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py
index c5cbe695243..4d4ffdc814e 100644
--- a/homeassistant/components/message_bird/notify.py
+++ b/homeassistant/components/message_bird/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import messagebird
from messagebird.client import ErrorException
@@ -55,7 +56,7 @@ class MessageBirdNotificationService(BaseNotificationService):
self.sender = sender
self.client = client
- def send_message(self, message=None, **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a specified target."""
if not (targets := kwargs.get(ATTR_TARGET)):
_LOGGER.error("No target specified")
diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py
index f666e2d614a..b46d876cd51 100644
--- a/homeassistant/components/mfi/sensor.py
+++ b/homeassistant/components/mfi/sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from mficlient.client import FailedToLogin, MFiClient
+from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
import requests
import voluptuous as vol
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
+ StateType,
)
from homeassistant.const import (
CONF_HOST,
@@ -64,24 +65,29 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up mFi sensors."""
- host = config.get(CONF_HOST)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- use_tls = config.get(CONF_SSL)
- verify_tls = config.get(CONF_VERIFY_SSL)
+ host: str = config[CONF_HOST]
+ username: str = config[CONF_USERNAME]
+ password: str = config[CONF_PASSWORD]
+ use_tls: bool = config[CONF_SSL]
+ verify_tls: bool = config[CONF_VERIFY_SSL]
default_port = 6443 if use_tls else 6080
- port = int(config.get(CONF_PORT, default_port))
+ network_port: int = config.get(CONF_PORT, default_port)
try:
client = MFiClient(
- host, username, password, port=port, use_tls=use_tls, verify=verify_tls
+ host,
+ username,
+ password,
+ port=network_port,
+ use_tls=use_tls,
+ verify=verify_tls,
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
return
add_entities(
- MfiSensor(port, hass)
+ MfiSensor(port)
for device in client.get_devices()
for port in device.ports.values()
if port.model in SENSOR_MODELS
@@ -91,18 +97,17 @@ def setup_platform(
class MfiSensor(SensorEntity):
"""Representation of a mFi sensor."""
- def __init__(self, port, hass):
+ def __init__(self, port: MFiPort) -> None:
"""Initialize the sensor."""
self._port = port
- self._hass = hass
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the sensor."""
return self._port.label
@property
- def native_value(self):
+ def native_value(self) -> StateType:
"""Return the state of the sensor."""
try:
tag = self._port.tag
@@ -116,7 +121,7 @@ class MfiSensor(SensorEntity):
return round(self._port.value, digits)
@property
- def device_class(self):
+ def device_class(self) -> SensorDeviceClass | None:
"""Return the device class of the sensor."""
try:
tag = self._port.tag
@@ -129,7 +134,7 @@ class MfiSensor(SensorEntity):
return None
@property
- def native_unit_of_measurement(self):
+ def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of this entity, if any."""
try:
tag = self._port.tag
diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py
index 2a05018f301..1fbf7f8cb82 100644
--- a/homeassistant/components/mfi/switch.py
+++ b/homeassistant/components/mfi/switch.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
-from mficlient.client import FailedToLogin, MFiClient
+from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
import requests
import voluptuous as vol
@@ -51,18 +51,23 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
- """Set up mFi sensors."""
- host = config.get(CONF_HOST)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- use_tls = config[CONF_SSL]
- verify_tls = config.get(CONF_VERIFY_SSL)
+ """Set up mFi switches."""
+ host: str = config[CONF_HOST]
+ username: str = config[CONF_USERNAME]
+ password: str = config[CONF_PASSWORD]
+ use_tls: bool = config[CONF_SSL]
+ verify_tls: bool = config[CONF_VERIFY_SSL]
default_port = 6443 if use_tls else 6080
- port = int(config.get(CONF_PORT, default_port))
+ network_port: int = config.get(CONF_PORT, default_port)
try:
client = MFiClient(
- host, username, password, port=port, use_tls=use_tls, verify=verify_tls
+ host,
+ username,
+ password,
+ port=network_port,
+ use_tls=use_tls,
+ verify=verify_tls,
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
@@ -79,23 +84,23 @@ def setup_platform(
class MfiSwitch(SwitchEntity):
"""Representation of an mFi switch-able device."""
- def __init__(self, port):
+ def __init__(self, port: MFiPort) -> None:
"""Initialize the mFi device."""
self._port = port
- self._target_state = None
+ self._target_state: bool | None = None
@property
- def unique_id(self):
+ def unique_id(self) -> str:
"""Return the unique ID of the device."""
return self._port.ident
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the device."""
return self._port.label
@property
- def is_on(self):
+ def is_on(self) -> bool | None:
"""Return true if the device is on."""
return self._port.output
diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py
index a9a920e3f52..3a8535b811b 100644
--- a/homeassistant/components/mill/climate.py
+++ b/homeassistant/components/mill/climate.py
@@ -7,6 +7,7 @@ from mill_local import OperationMode
import voluptuous as vol
from homeassistant.components.climate import (
+ ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -111,13 +112,16 @@ class MillHeater(MillBaseEntity, ClimateEntity):
super().__init__(coordinator, device)
async def async_set_temperature(self, **kwargs: Any) -> None:
- """Set new target temperature."""
+ """Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_heater_temp(
self._id, float(temperature)
)
- await self.coordinator.async_request_refresh()
+ if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
+ await self.async_handle_set_hvac_mode_service(hvac_mode)
+ else:
+ await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -125,12 +129,11 @@ class MillHeater(MillBaseEntity, ClimateEntity):
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=True
)
- await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=False
)
- await self.coordinator.async_request_refresh()
+ await self.coordinator.async_request_refresh()
@callback
def _update_attr(self, device: mill.Heater) -> None:
@@ -189,25 +192,26 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
self._update_attr()
async def async_set_temperature(self, **kwargs: Any) -> None:
- """Set new target temperature."""
+ """Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_target_temperature(
float(temperature)
)
- await self.coordinator.async_request_refresh()
+ if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
+ await self.async_handle_set_hvac_mode_service(hvac_mode)
+ else:
+ await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
- await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
- await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
- await self.coordinator.async_request_refresh()
+ await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:
diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py
index 8eb556319f9..cc35f0ff72b 100644
--- a/homeassistant/components/minecraft_server/api.py
+++ b/homeassistant/components/minecraft_server/api.py
@@ -5,8 +5,12 @@ from enum import StrEnum
import logging
from dns.resolver import LifetimeTimeout
-from mcstatus import BedrockServer, JavaServer
-from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse
+from mcstatus import BedrockServer, JavaServer, LegacyServer
+from mcstatus.responses import (
+ BedrockStatusResponse,
+ JavaStatusResponse,
+ LegacyStatusResponse,
+)
from homeassistant.core import HomeAssistant
@@ -43,6 +47,7 @@ class MinecraftServerType(StrEnum):
BEDROCK_EDITION = "Bedrock Edition"
JAVA_EDITION = "Java Edition"
+ LEGACY_JAVA_EDITION = "Legacy Java Edition"
class MinecraftServerAddressError(Exception):
@@ -60,7 +65,7 @@ class MinecraftServerNotInitializedError(Exception):
class MinecraftServer:
"""Minecraft Server wrapper class for 3rd party library mcstatus."""
- _server: BedrockServer | JavaServer | None
+ _server: BedrockServer | JavaServer | LegacyServer | None
def __init__(
self, hass: HomeAssistant, server_type: MinecraftServerType, address: str
@@ -76,10 +81,12 @@ class MinecraftServer:
try:
if self._server_type == MinecraftServerType.JAVA_EDITION:
self._server = await JavaServer.async_lookup(self._address)
- else:
+ elif self._server_type == MinecraftServerType.BEDROCK_EDITION:
self._server = await self._hass.async_add_executor_job(
BedrockServer.lookup, self._address
)
+ else:
+ self._server = await LegacyServer.async_lookup(self._address)
except (ValueError, LifetimeTimeout) as error:
raise MinecraftServerAddressError(
f"Lookup of '{self._address}' failed: {self._get_error_message(error)}"
@@ -112,7 +119,9 @@ class MinecraftServer:
async def async_get_data(self) -> MinecraftServerData:
"""Get updated data from the server, supporting both Java and Bedrock Edition servers."""
- status_response: BedrockStatusResponse | JavaStatusResponse
+ status_response: (
+ BedrockStatusResponse | JavaStatusResponse | LegacyStatusResponse
+ )
if self._server is None:
raise MinecraftServerNotInitializedError(
@@ -128,8 +137,10 @@ class MinecraftServer:
if isinstance(status_response, JavaStatusResponse):
data = self._extract_java_data(status_response)
- else:
+ elif isinstance(status_response, BedrockStatusResponse):
data = self._extract_bedrock_data(status_response)
+ else:
+ data = self._extract_legacy_data(status_response)
return data
@@ -169,6 +180,19 @@ class MinecraftServer:
map_name=status_response.map_name,
)
+ def _extract_legacy_data(
+ self, status_response: LegacyStatusResponse
+ ) -> MinecraftServerData:
+ """Extract legacy Java Edition server data out of status response."""
+ return MinecraftServerData(
+ latency=status_response.latency,
+ motd=status_response.motd.to_plain(),
+ players_max=status_response.players.max,
+ players_online=status_response.players.online,
+ protocol_version=status_response.version.protocol,
+ version=status_response.version.name,
+ )
+
def _get_error_message(self, error: BaseException) -> str:
"""Get error message of an exception."""
if not str(error):
diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py
index d0f7cf5a8fb..4bcb5f6cb88 100644
--- a/homeassistant/components/minecraft_server/config_flow.py
+++ b/homeassistant/components/minecraft_server/config_flow.py
@@ -84,4 +84,5 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
+ description_placeholders={"minimum_minecraft_version": "1.4"},
)
diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json
index 1d1c02cae81..f421be8cc83 100644
--- a/homeassistant/components/minecraft_server/manifest.json
+++ b/homeassistant/components/minecraft_server/manifest.json
@@ -1,12 +1,12 @@
{
"domain": "minecraft_server",
"name": "Minecraft Server",
- "codeowners": ["@elmurato"],
+ "codeowners": ["@elmurato", "@zachdeibert"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
"quality_scale": "silver",
- "requirements": ["mcstatus==12.0.6"]
+ "requirements": ["mcstatus==12.1.0"]
}
diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py
index cfc16c7724d..c7eecec3f0d 100644
--- a/homeassistant/components/minecraft_server/sensor.py
+++ b/homeassistant/components/minecraft_server/sensor.py
@@ -65,6 +65,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -76,6 +77,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -89,6 +91,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_registry_enabled_default=False,
),
@@ -102,6 +105,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -113,6 +117,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
),
MinecraftServerSensorEntityDescription(
@@ -124,6 +129,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
+ MinecraftServerType.LEGACY_JAVA_EDITION,
},
),
MinecraftServerSensorEntityDescription(
diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json
index 0867db90bd8..8dfc11845b4 100644
--- a/homeassistant/components/minecraft_server/strings.json
+++ b/homeassistant/components/minecraft_server/strings.json
@@ -4,7 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
- "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7."
+ "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version {minimum_minecraft_version}."
},
"step": {
"user": {
diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py
index 33c0442b529..cd08ac5cb93 100644
--- a/homeassistant/components/mobile_app/config_flow.py
+++ b/homeassistant/components/mobile_app/config_flow.py
@@ -46,7 +46,7 @@ class MobileAppFlowHandler(ConfigFlow, domain=DOMAIN):
"device_tracker",
DOMAIN,
user_input[ATTR_DEVICE_ID],
- suggested_object_id=user_input[ATTR_DEVICE_NAME],
+ object_id_base=user_input[ATTR_DEVICE_NAME],
)
await person.async_add_user_device_tracker(
self.hass, user_input[CONF_USER_ID], devt_entry.entity_id
diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json
index 6e4651ab0db..e1e394be363 100644
--- a/homeassistant/components/mobile_app/manifest.json
+++ b/homeassistant/components/mobile_app/manifest.json
@@ -16,5 +16,5 @@
"iot_class": "local_push",
"loggers": ["nacl"],
"quality_scale": "internal",
- "requirements": ["PyNaCl==1.6.0"]
+ "requirements": ["PyNaCl==1.6.2"]
}
diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py
index 1980c80ce69..a7d15e32853 100644
--- a/homeassistant/components/mobile_app/notify.py
+++ b/homeassistant/components/mobile_app/notify.py
@@ -6,6 +6,7 @@ import asyncio
from functools import partial
from http import HTTPStatus
import logging
+from typing import Any
import aiohttp
@@ -47,7 +48,7 @@ from .util import supports_push
_LOGGER = logging.getLogger(__name__)
-def push_registrations(hass):
+def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
targets = {}
@@ -90,38 +91,32 @@ async def async_get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MobileAppNotificationService:
"""Get the mobile_app notification service."""
- service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService(hass)
+ service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService()
return service
class MobileAppNotificationService(BaseNotificationService):
"""Implement the notification service for mobile_app."""
- def __init__(self, hass):
- """Initialize the service."""
- self._hass = hass
-
@property
- def targets(self):
+ def targets(self) -> dict[str, str]:
"""Return a dictionary of registered targets."""
return push_registrations(self.hass)
- async def async_send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the Lambda APNS gateway."""
data = {ATTR_MESSAGE: message}
# Remove default title from notifications.
if (
- kwargs.get(ATTR_TITLE) is not None
- and kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT
- ):
- data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
-
+ title_arg := kwargs.get(ATTR_TITLE)
+ ) is not None and title_arg != ATTR_TITLE_DEFAULT:
+ data[ATTR_TITLE] = title_arg
if not (targets := kwargs.get(ATTR_TARGET)):
targets = push_registrations(self.hass).values()
- if kwargs.get(ATTR_DATA) is not None:
- data[ATTR_DATA] = kwargs.get(ATTR_DATA)
+ if (data_arg := kwargs.get(ATTR_DATA)) is not None:
+ data[ATTR_DATA] = data_arg
local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL]
@@ -166,7 +161,7 @@ class MobileAppNotificationService(BaseNotificationService):
try:
async with asyncio.timeout(10):
- response = await async_get_clientsession(self._hass).post(
+ response = await async_get_clientsession(self.hass).post(
push_url, json=target_data
)
result = await response.json()
diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py
index 0a9609dfc6d..0d577a76d80 100644
--- a/homeassistant/components/mqtt/schemas.py
+++ b/homeassistant/components/mqtt/schemas.py
@@ -73,15 +73,6 @@ SHARED_OPTIONS = [
CONF_STATE_TOPIC,
]
-MQTT_ORIGIN_INFO_SCHEMA = vol.All(
- vol.Schema(
- {
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_SW_VERSION): cv.string,
- vol.Optional(CONF_SUPPORT_URL): cv.configuration_url,
- }
- ),
-)
_MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py
index 06f9bc42e91..47ec9f04637 100644
--- a/homeassistant/components/msteams/notify.py
+++ b/homeassistant/components/msteams/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import pymsteams
import voluptuous as vol
@@ -49,7 +50,7 @@ class MSTeamsNotificationService(BaseNotificationService):
"""Initialize the service."""
self._webhook_url = webhook_url
- def send_message(self, message=None, **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to the webhook."""
teams_message = pymsteams.connectorcard(self._webhook_url)
diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py
index 67203ae0564..19e29004be8 100644
--- a/homeassistant/components/mycroft/notify.py
+++ b/homeassistant/components/mycroft/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from mycroftapi import MycroftAPI
@@ -10,6 +11,8 @@ from homeassistant.components.notify import BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from . import DOMAIN
+
_LOGGER = logging.getLogger(__name__)
@@ -19,17 +22,17 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MycroftNotificationService:
"""Get the Mycroft notification service."""
- return MycroftNotificationService(hass.data["mycroft"])
+ return MycroftNotificationService(hass.data[DOMAIN])
class MycroftNotificationService(BaseNotificationService):
"""The Mycroft Notification Service."""
- def __init__(self, mycroft_ip):
+ def __init__(self, mycroft_ip: str) -> None:
"""Initialize the service."""
self.mycroft_ip = mycroft_ip
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message mycroft to speak on instance."""
text = message
@@ -37,4 +40,4 @@ class MycroftNotificationService(BaseNotificationService):
if mycroft is not None:
mycroft.speak_text(text)
else:
- _LOGGER.log("Could not reach this instance of mycroft")
+ _LOGGER.warning("Could not reach this instance of mycroft")
diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py
index 7fbd49d979b..f72441f6e58 100644
--- a/homeassistant/components/namecheapdns/__init__.py
+++ b/homeassistant/components/namecheapdns/__init__.py
@@ -1,25 +1,20 @@
"""Support for namecheap DNS services."""
-from datetime import timedelta
import logging
-import defusedxml.ElementTree as ET
import voluptuous as vol
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
+from .const import DOMAIN
+from .coordinator import NamecheapConfigEntry, NamecheapDnsUpdateCoordinator
+
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "namecheapdns"
-
-INTERVAL = timedelta(minutes=5)
-
-UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
CONFIG_SCHEMA = vol.Schema(
{
@@ -37,37 +32,30 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the namecheap DNS component."""
- host = config[DOMAIN][CONF_HOST]
- domain = config[DOMAIN][CONF_DOMAIN]
- password = config[DOMAIN][CONF_PASSWORD]
- session = async_get_clientsession(hass)
-
- result = await _update_namecheapdns(session, host, domain, password)
-
- if not result:
- return False
-
- async def update_domain_interval(now):
- """Update the namecheap DNS entry."""
- await _update_namecheapdns(session, host, domain, password)
-
- async_track_time_interval(hass, update_domain_interval, INTERVAL)
-
- return result
-
-
-async def _update_namecheapdns(session, host, domain, password):
- """Update namecheap DNS entry."""
- params = {"host": host, "domain": domain, "password": password}
-
- resp = await session.get(UPDATE_URL, params=params)
- xml_string = await resp.text()
- root = ET.fromstring(xml_string)
- err_count = root.find("ErrCount").text
-
- if int(err_count) != 0:
- _LOGGER.warning("Updating namecheap domain failed: %s", domain)
- return False
+ if DOMAIN in config:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
+ )
+ )
return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
+ """Set up Namecheap DynamicDNS from a config entry."""
+
+ coordinator = NamecheapDnsUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+
+ # Add a dummy listener as we do not have regular entities
+ entry.async_on_unload(coordinator.async_add_listener(lambda: None))
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
+ """Unload a config entry."""
+ return True
diff --git a/homeassistant/components/namecheapdns/config_flow.py b/homeassistant/components/namecheapdns/config_flow.py
new file mode 100644
index 00000000000..484bbe30269
--- /dev/null
+++ b/homeassistant/components/namecheapdns/config_flow.py
@@ -0,0 +1,139 @@
+"""Config flow for the Namecheap DynamicDNS integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from aiohttp import ClientError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_NAME, CONF_PASSWORD
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import DOMAIN
+from .helpers import update_namecheapdns
+from .issue import deprecate_yaml_issue
+
+_LOGGER = logging.getLogger(__name__)
+
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST, default="@"): cv.string,
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD, autocomplete="current-password"
+ )
+ ),
+ }
+)
+
+STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD, autocomplete="current-password"
+ )
+ ),
+ }
+)
+
+
+class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Namecheap DynamicDNS."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self._async_abort_entries_match(
+ {CONF_HOST: user_input[CONF_HOST], CONF_DOMAIN: user_input[CONF_DOMAIN]}
+ )
+ session = async_get_clientsession(self.hass)
+ try:
+ if not await update_namecheapdns(session, **user_input):
+ errors["base"] = "update_failed"
+ except ClientError:
+ _LOGGER.debug("Cannot connect", exc_info=True)
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if not errors:
+ return self.async_create_entry(
+ title=f"{user_input[CONF_HOST]}.{user_input[CONF_DOMAIN]}",
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
+ ),
+ errors=errors,
+ description_placeholders={"account_panel": "https://ap.www.namecheap.com/"},
+ )
+
+ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
+ """Import config from yaml."""
+
+ self._async_abort_entries_match(
+ {CONF_HOST: import_info[CONF_HOST], CONF_DOMAIN: import_info[CONF_DOMAIN]}
+ )
+ result = await self.async_step_user(import_info)
+ if errors := result.get("errors"):
+ deprecate_yaml_issue(self.hass, import_success=False)
+ return self.async_abort(reason=errors["base"])
+
+ deprecate_yaml_issue(self.hass, import_success=True)
+ return result
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfigure flow."""
+ errors: dict[str, str] = {}
+
+ entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ session = async_get_clientsession(self.hass)
+ try:
+ if not await update_namecheapdns(
+ session,
+ entry.data[CONF_HOST],
+ entry.data[CONF_DOMAIN],
+ user_input[CONF_PASSWORD],
+ ):
+ errors["base"] = "update_failed"
+ except ClientError:
+ _LOGGER.debug("Cannot connect", exc_info=True)
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if not errors:
+ return self.async_update_reload_and_abort(
+ entry,
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
+ errors=errors,
+ description_placeholders={CONF_NAME: entry.title},
+ )
diff --git a/homeassistant/components/namecheapdns/const.py b/homeassistant/components/namecheapdns/const.py
new file mode 100644
index 00000000000..84193fac90c
--- /dev/null
+++ b/homeassistant/components/namecheapdns/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Namecheap DynamicDNS integration."""
+
+DOMAIN = "namecheapdns"
+
+
+UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
diff --git a/homeassistant/components/namecheapdns/coordinator.py b/homeassistant/components/namecheapdns/coordinator.py
new file mode 100644
index 00000000000..f074330ad64
--- /dev/null
+++ b/homeassistant/components/namecheapdns/coordinator.py
@@ -0,0 +1,61 @@
+"""Coordinator for the Namecheap DynamicDNS integration."""
+
+from datetime import timedelta
+import logging
+
+from aiohttp import ClientError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+from .helpers import update_namecheapdns
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type NamecheapConfigEntry = ConfigEntry[NamecheapDnsUpdateCoordinator]
+
+
+INTERVAL = timedelta(minutes=5)
+
+
+class NamecheapDnsUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Namecheap DynamicDNS update coordinator."""
+
+ config_entry: NamecheapConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: NamecheapConfigEntry) -> None:
+ """Initialize the Namecheap DynamicDNS update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=INTERVAL,
+ )
+
+ self.session = async_get_clientsession(hass)
+
+ async def _async_update_data(self) -> None:
+ """Update Namecheap DNS."""
+ host = self.config_entry.data[CONF_HOST]
+ domain = self.config_entry.data[CONF_DOMAIN]
+ password = self.config_entry.data[CONF_PASSWORD]
+
+ try:
+ if not await update_namecheapdns(self.session, host, domain, password):
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
+ )
+ except ClientError as e:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="connection_error",
+ translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
+ ) from e
diff --git a/homeassistant/components/namecheapdns/helpers.py b/homeassistant/components/namecheapdns/helpers.py
new file mode 100644
index 00000000000..90d32819a7c
--- /dev/null
+++ b/homeassistant/components/namecheapdns/helpers.py
@@ -0,0 +1,24 @@
+"""Helpers for the Namecheap DynamicDNS integration."""
+
+import logging
+
+from aiohttp import ClientSession
+
+from .const import UPDATE_URL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def update_namecheapdns(
+ session: ClientSession, host: str, domain: str, password: str
+):
+ """Update namecheap DNS entry."""
+ params = {"host": host, "domain": domain, "password": password}
+
+ resp = await session.get(UPDATE_URL, params=params)
+ xml_string = await resp.text()
+
+ if "0" not in xml_string:
+ return False
+
+ return True
diff --git a/homeassistant/components/namecheapdns/issue.py b/homeassistant/components/namecheapdns/issue.py
new file mode 100644
index 00000000000..e32e0db6c81
--- /dev/null
+++ b/homeassistant/components/namecheapdns/issue.py
@@ -0,0 +1,40 @@
+"""Issues for Namecheap DynamicDNS integration."""
+
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+
+from .const import DOMAIN
+
+
+@callback
+def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
+ """Deprecate yaml issue."""
+ if import_success:
+ async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ breaks_in_ha_version="2026.8.0",
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Namecheap DynamicDNS",
+ },
+ )
+ else:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_import_issue_error",
+ breaks_in_ha_version="2026.8.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_yaml_import_issue_error",
+ translation_placeholders={
+ "url": f"/config/integrations/dashboard/add?domain={DOMAIN}"
+ },
+ )
diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json
index f50d6aed63e..cb8b708a202 100644
--- a/homeassistant/components/namecheapdns/manifest.json
+++ b/homeassistant/components/namecheapdns/manifest.json
@@ -1,9 +1,10 @@
{
"domain": "namecheapdns",
"name": "Namecheap DynamicDNS",
- "codeowners": [],
+ "codeowners": ["@tr4nt0r"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
+ "integration_type": "service",
"iot_class": "cloud_push",
- "quality_scale": "legacy",
- "requirements": ["defusedxml==0.7.1"]
+ "requirements": []
}
diff --git a/homeassistant/components/namecheapdns/strings.json b/homeassistant/components/namecheapdns/strings.json
new file mode 100644
index 00000000000..b419c6261b8
--- /dev/null
+++ b/homeassistant/components/namecheapdns/strings.json
@@ -0,0 +1,51 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "update_failed": "Updating DNS failed"
+ },
+ "step": {
+ "reconfigure": {
+ "data": {
+ "password": "[%key:component::namecheapdns::config::step::user::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::namecheapdns::config::step::user::data_description::password%]"
+ },
+ "title": "Re-configure {name}"
+ },
+ "user": {
+ "data": {
+ "domain": "[%key:common::config_flow::data::username%]",
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "Dynamic DNS password"
+ },
+ "data_description": {
+ "domain": "The domain to update ('example.com')",
+ "host": "The host to update ('home' for home.example.com). Use '@' to update the root domain",
+ "password": "Dynamic DNS password for the domain"
+ },
+ "description": "Enter your Namecheap DynamicDNS domain and password below to configure dynamic DNS updates. You can find the Dynamic DNS password in your [Namecheap account]({account_panel}) under Domain List > Manage > Advanced DNS > Dynamic DNS."
+ }
+ }
+ },
+ "exceptions": {
+ "connection_error": {
+ "message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error"
+ },
+ "update_failed": {
+ "message": "Updating Namecheap DynamicDNS domain {domain} failed"
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_error": {
+ "description": "Configuring Namecheap DynamicDNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Namecheap DynamicDNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
+ "title": "The Namecheap DynamicDNS YAML configuration import failed"
+ }
+ }
+}
diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py
index 28036d73e95..a95c48f0f81 100644
--- a/homeassistant/components/nasweb/__init__.py
+++ b/homeassistant/components/nasweb/__init__.py
@@ -21,6 +21,7 @@ from .nasweb_data import NASwebData
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
+ Platform.CLIMATE,
Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/nasweb/climate.py b/homeassistant/components/nasweb/climate.py
new file mode 100644
index 00000000000..0b13f015b0d
--- /dev/null
+++ b/homeassistant/components/nasweb/climate.py
@@ -0,0 +1,168 @@
+"""Platform for NASweb thermostat."""
+
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from webio_api import Thermostat as NASwebThermostat
+from webio_api.const import KEY_THERMOSTAT
+
+from homeassistant.components.climate import (
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+ UnitOfTemperature,
+)
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import DiscoveryInfoType
+from homeassistant.helpers.update_coordinator import (
+ BaseCoordinatorEntity,
+ BaseDataUpdateCoordinatorProtocol,
+)
+
+from . import NASwebConfigEntry
+from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL
+
+CLIMATE_TRANSLATION_KEY = "thermostat"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config: NASwebConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up Climate platform."""
+ coordinator = config.runtime_data
+ nasweb_thermostat: NASwebThermostat = coordinator.data[KEY_THERMOSTAT]
+ climate = Thermostat(coordinator, nasweb_thermostat)
+ async_add_entities([climate])
+
+
+class Thermostat(ClimateEntity, BaseCoordinatorEntity):
+ """Entity representing NASweb thermostat."""
+
+ _attr_device_class = SensorDeviceClass.TEMPERATURE
+ _attr_has_entity_name = True
+ _attr_hvac_modes = [
+ HVACMode.OFF,
+ HVACMode.HEAT,
+ HVACMode.COOL,
+ HVACMode.HEAT_COOL,
+ HVACMode.FAN_ONLY,
+ ]
+ _attr_max_temp = 50
+ _attr_min_temp = -50
+ _attr_precision = 1.0
+ _attr_should_poll = False
+ _attr_supported_features = ClimateEntityFeature(
+ ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ )
+ _attr_target_temperature_step = 1.0
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_translation_key = CLIMATE_TRANSLATION_KEY
+
+ def __init__(
+ self,
+ coordinator: BaseDataUpdateCoordinatorProtocol,
+ nasweb_thermostat: NASwebThermostat,
+ ) -> None:
+ """Initialize Thermostat."""
+ super().__init__(coordinator)
+ self._thermostat = nasweb_thermostat
+ self._attr_available = False
+ self._attr_name = nasweb_thermostat.name
+ self._attr_unique_id = f"{DOMAIN}.{self._thermostat.webio_serial}.thermostat"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._thermostat.webio_serial)}
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self._handle_coordinator_update()
+
+ def _set_attr_available(
+ self, entity_last_update: float, available: bool | None
+ ) -> None:
+ if (
+ self.coordinator.last_update is None
+ or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL
+ ):
+ self._attr_available = False
+ else:
+ self._attr_available = available if available is not None else False
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._attr_current_temperature = self._thermostat.current_temp
+ self._attr_target_temperature_low = self._thermostat.temp_target_min
+ self._attr_target_temperature_high = self._thermostat.temp_target_max
+ self._attr_hvac_mode = self._get_current_hvac_mode()
+ self._attr_hvac_action = self._get_current_action()
+ self._attr_name = self._thermostat.name if self._thermostat.name else None
+ self._set_attr_available(
+ self._thermostat.last_update, self._thermostat.available
+ )
+ self.async_write_ha_state()
+
+ def _get_current_hvac_mode(self) -> HVACMode:
+ have_cooling = self._thermostat.enabled_above_output
+ have_heating = self._thermostat.enabled_below_output
+ if have_cooling and have_heating:
+ return HVACMode.HEAT_COOL
+ if have_cooling:
+ return HVACMode.COOL
+ if have_heating:
+ return HVACMode.HEAT
+ if self._thermostat.enabled_inrange_output:
+ return HVACMode.FAN_ONLY
+ return HVACMode.OFF
+
+ def _get_current_action(self) -> HVACAction:
+ if self._thermostat.current_temp is None:
+ return HVACAction.OFF
+ if (
+ self._thermostat.temp_target_min is not None
+ and self._thermostat.current_temp < self._thermostat.temp_target_min
+ and self._thermostat.enabled_below_output
+ ):
+ return HVACAction.HEATING
+ if (
+ self._thermostat.temp_target_max is not None
+ and self._thermostat.current_temp > self._thermostat.temp_target_max
+ and self._thermostat.enabled_above_output
+ ):
+ return HVACAction.COOLING
+ if (
+ self._thermostat.temp_target_min is not None
+ and self._thermostat.temp_target_max is not None
+ and self._thermostat.current_temp >= self._thermostat.temp_target_min
+ and self._thermostat.current_temp <= self._thermostat.temp_target_max
+ and self._thermostat.enabled_inrange_output
+ ):
+ return HVACAction.FAN
+ return HVACAction.IDLE
+
+ async def async_update(self) -> None:
+ """Update the entity.
+
+ Only used by the generic entity update service.
+ Scheduling updates is not necessary, the coordinator takes care of updates via push notifications.
+ """
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set HVACMode for Thermostat."""
+ await self._thermostat.set_hvac_mode(hvac_mode)
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set temperature range for Thermostat."""
+ await self._thermostat.set_temperature(
+ kwargs["target_temp_low"], kwargs["target_temp_high"]
+ )
diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py
index 2536de1a2d8..e27b81d62a6 100644
--- a/homeassistant/components/nasweb/coordinator.py
+++ b/homeassistant/components/nasweb/coordinator.py
@@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__)
KEY_INPUTS = "inputs"
KEY_OUTPUTS = "outputs"
+KEY_THERMOSTAT = "thermostat"
KEY_ZONES = "zones"
@@ -104,6 +105,7 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
+ KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(data)
@@ -199,6 +201,7 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
+ KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(new_data)
diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json
index 274e1a29a09..4ec206f3ae5 100644
--- a/homeassistant/components/nasweb/strings.json
+++ b/homeassistant/components/nasweb/strings.json
@@ -29,6 +29,11 @@
"name": "Zone {index}"
}
},
+ "climate": {
+ "thermostat": {
+ "name": "[%key:component::climate::entity_component::_::name%]"
+ }
+ },
"sensor": {
"sensor_input": {
"name": "Input {index}",
diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json
index cba85d0197c..81423d93cae 100644
--- a/homeassistant/components/nederlandse_spoorwegen/manifest.json
+++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "legacy",
"requirements": ["nsapi==3.1.3"]
}
diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py
index 730a9aff765..f9ed94a014b 100644
--- a/homeassistant/components/ness_alarm/__init__.py
+++ b/homeassistant/components/ness_alarm/__init__.py
@@ -1,8 +1,8 @@
"""Support for Ness D8X/D16X devices."""
-from collections import namedtuple
import datetime
import logging
+from typing import NamedTuple
from nessclient import ArmingMode, ArmingState, Client
import voluptuous as vol
@@ -25,11 +25,12 @@ from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
DOMAIN = "ness_alarm"
-DATA_NESS = "ness_alarm"
+DATA_NESS: HassKey[Client] = HassKey(DOMAIN)
CONF_DEVICE_PORT = "port"
CONF_INFER_ARMING_STATE = "infer_arming_state"
@@ -44,7 +45,13 @@ DEFAULT_INFER_ARMING_STATE = False
SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed"
SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed"
-ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"]) # noqa: PYI024
+
+class ZoneChangedData(NamedTuple):
+ """Data for a zone state change."""
+
+ zone_id: int
+ state: bool
+
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
ZONE_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/ness_alarm/binary_sensor.py b/homeassistant/components/ness_alarm/binary_sensor.py
index bb0fa38ef72..8feaa6c696b 100644
--- a/homeassistant/components/ness_alarm/binary_sensor.py
+++ b/homeassistant/components/ness_alarm/binary_sensor.py
@@ -2,7 +2,10 @@
from __future__ import annotations
-from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -30,18 +33,14 @@ async def async_setup_platform(
configured_zones = discovery_info[CONF_ZONES]
- devices = []
-
- for zone_config in configured_zones:
- zone_type = zone_config[CONF_ZONE_TYPE]
- zone_name = zone_config[CONF_ZONE_NAME]
- zone_id = zone_config[CONF_ZONE_ID]
- device = NessZoneBinarySensor(
- zone_id=zone_id, name=zone_name, zone_type=zone_type
+ async_add_entities(
+ NessZoneBinarySensor(
+ zone_id=zone_config[CONF_ZONE_ID],
+ name=zone_config[CONF_ZONE_NAME],
+ zone_type=zone_config[CONF_ZONE_TYPE],
)
- devices.append(device)
-
- async_add_entities(devices)
+ for zone_config in configured_zones
+ )
class NessZoneBinarySensor(BinarySensorEntity):
@@ -49,12 +48,14 @@ class NessZoneBinarySensor(BinarySensorEntity):
_attr_should_poll = False
- def __init__(self, zone_id, name, zone_type):
+ def __init__(
+ self, zone_id: int, name: str, zone_type: BinarySensorDeviceClass
+ ) -> None:
"""Initialize the binary_sensor."""
self._zone_id = zone_id
- self._name = name
- self._type = zone_type
- self._state = 0
+ self._attr_name = name
+ self._attr_device_class = zone_type
+ self._attr_is_on = False
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -64,24 +65,9 @@ class NessZoneBinarySensor(BinarySensorEntity):
)
)
- @property
- def name(self):
- """Return the name of the entity."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if sensor is on."""
- return self._state == 1
-
- @property
- def device_class(self):
- """Return the class of this sensor, from DEVICE_CLASSES."""
- return self._type
-
@callback
- def _handle_zone_change(self, data: ZoneChangedData):
+ def _handle_zone_change(self, data: ZoneChangedData) -> None:
"""Handle zone state update."""
if self._zone_id == data.zone_id:
- self._state = data.state
+ self._attr_is_on = data.state
self.async_write_ha_state()
diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py
index 5b10398e055..8788c00ac75 100644
--- a/homeassistant/components/netgear_lte/notify.py
+++ b/homeassistant/components/netgear_lte/notify.py
@@ -40,7 +40,7 @@ class NetgearNotifyService(BaseNotificationService):
self.modem: Modem = discovery_info["modem"]
discovery_info["entry"].async_on_unload(self.async_unregister_services)
- async def async_send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
if not self.modem.token:
diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py
index 52ff87e11c7..1e698713935 100644
--- a/homeassistant/components/nexia/climate.py
+++ b/homeassistant/components/nexia/climate.py
@@ -225,7 +225,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
self._signal_thermostat_update()
@property
- def preset_mode(self):
+ def preset_mode(self) -> str | None:
"""Preset that is active."""
return self._zone.get_preset()
diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py
index 42b68a4c76a..2eeec4de19d 100644
--- a/homeassistant/components/nina/config_flow.py
+++ b/homeassistant/components/nina/config_flow.py
@@ -141,7 +141,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
try:
self._all_region_codes_sorted = swap_key_value(
- await nina.getAllRegionalCodes()
+ await nina.get_all_regional_codes()
)
except ApiError:
return self.async_abort(reason="no_fetch")
@@ -221,7 +221,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
try:
self._all_region_codes_sorted = swap_key_value(
- await nina.getAllRegionalCodes()
+ await nina.get_all_regional_codes()
)
except ApiError:
return self.async_abort(reason="no_fetch")
diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py
index 7097b24e41f..175b128fdba 100644
--- a/homeassistant/components/nina/coordinator.py
+++ b/homeassistant/components/nina/coordinator.py
@@ -66,7 +66,7 @@ class NINADataUpdateCoordinator(
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
for region in regions:
- self._nina.addRegion(region)
+ self._nina.add_region(region)
super().__init__(
hass,
@@ -151,7 +151,7 @@ class NINADataUpdateCoordinator(
raw_warn.sent or "",
raw_warn.start or "",
raw_warn.expires or "",
- raw_warn.isValid(),
+ raw_warn.is_valid,
)
warnings_for_regions.append(warning_data)
diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json
index 85ac355c08d..80bcb4d24b1 100644
--- a/homeassistant/components/nina/manifest.json
+++ b/homeassistant/components/nina/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["pynina"],
"quality_scale": "bronze",
- "requirements": ["pynina==0.3.6"],
+ "requirements": ["pynina==1.0.2"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml
index 8baa258e1d1..b4d8e14db85 100644
--- a/homeassistant/components/nina/quality_scale.yaml
+++ b/homeassistant/components/nina/quality_scale.yaml
@@ -47,12 +47,8 @@ rules:
test-coverage:
status: todo
comment: |
- Use load_json_object_fixture in tests
- Patch the library instead of the HTTP requests
Create a shared fixture for the mock config entry
- Use snapshots for binary sensor tests
Use init_integration in tests
- Evaluate the need of test_config_entry_not_ready
# Gold
devices: done
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
index dfeec3e3c9b..3b5831474ee 100644
--- a/homeassistant/components/nmbs/manifest.json
+++ b/homeassistant/components/nmbs/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
- "quality_scale": "legacy",
"requirements": ["pyrail==0.4.1"]
}
diff --git a/homeassistant/components/notify_events/notify.py b/homeassistant/components/notify_events/notify.py
index bfe0e4a2a57..92628059d68 100644
--- a/homeassistant/components/notify_events/notify.py
+++ b/homeassistant/components/notify_events/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os.path
+from typing import Any
from notify_events import Message
@@ -123,7 +124,7 @@ class NotifyEventsNotificationService(BaseNotificationService):
return msg
- def send_message(self, message, **kwargs):
+ def send_message(self, message: str, **kwargs: Any) -> None:
"""Send a message."""
data = kwargs.get(ATTR_DATA) or {}
token = data.get(ATTR_TOKEN, self.token)
diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json
index 9d0960dfbf6..1be3c30ba49 100644
--- a/homeassistant/components/ntfy/manifest.json
+++ b/homeassistant/components/ntfy/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aionfty"],
"quality_scale": "platinum",
- "requirements": ["aiontfy==0.6.1"]
+ "requirements": ["aiontfy==0.7.0"]
}
diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py
index 85e24c116f9..6a38bb160be 100644
--- a/homeassistant/components/nuheat/climate.py
+++ b/homeassistant/components/nuheat/climate.py
@@ -154,7 +154,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity):
return nuheat_to_fahrenheit(self._target_temperature)
@property
- def preset_mode(self):
+ def preset_mode(self) -> str:
"""Return current preset mode."""
return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN)
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index d63f2ff1657..4400e76191b 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -125,7 +125,7 @@ class NumberDeviceClass(StrEnum):
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
- Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³`
"""
CO2 = "carbon_dioxide"
@@ -247,7 +247,7 @@ class NumberDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -265,7 +265,7 @@ class NumberDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
PH = "ph"
@@ -373,7 +373,7 @@ class NumberDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -483,6 +483,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.BATTERY: {PERCENTAGE},
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
NumberDeviceClass.CO: {
+ CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -516,10 +517,16 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
NumberDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
NumberDeviceClass.MOISTURE: {PERCENTAGE},
- NumberDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ NumberDeviceClass.NITROGEN_DIOXIDE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
NumberDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
- NumberDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ NumberDeviceClass.OZONE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
NumberDeviceClass.PH: {None},
NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
@@ -545,7 +552,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
},
NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
- NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ NumberDeviceClass.SULPHUR_DIOXIDE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature),
NumberDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py
index 69e2f626049..91d50591dfb 100644
--- a/homeassistant/components/nx584/binary_sensor.py
+++ b/homeassistant/components/nx584/binary_sensor.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import threading
import time
+from typing import Any
from nx584 import client as nx584_client
import requests
@@ -28,8 +29,7 @@ CONF_EXCLUDE_ZONES = "exclude_zones"
CONF_ZONE_TYPES = "zone_types"
DEFAULT_HOST = "localhost"
-DEFAULT_PORT = "5007"
-DEFAULT_SSL = False
+DEFAULT_PORT = 5007
ZONE_TYPES_SCHEMA = vol.Schema({cv.positive_int: BINARY_SENSOR_DEVICE_CLASSES_SCHEMA})
@@ -53,10 +53,10 @@ def setup_platform(
) -> None:
"""Set up the NX584 binary sensor platform."""
- host = config[CONF_HOST]
- port = config[CONF_PORT]
- exclude = config[CONF_EXCLUDE_ZONES]
- zone_types = config[CONF_ZONE_TYPES]
+ host: str = config[CONF_HOST]
+ port: int = config[CONF_PORT]
+ exclude: list[int] = config[CONF_EXCLUDE_ZONES]
+ zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES]
try:
client = nx584_client.Client(f"http://{host}:{port}")
@@ -90,15 +90,12 @@ class NX584ZoneSensor(BinarySensorEntity):
_attr_should_poll = False
- def __init__(self, zone, zone_type):
+ def __init__(
+ self, zone: dict[str, Any], zone_type: BinarySensorDeviceClass
+ ) -> None:
"""Initialize the nx594 binary sensor."""
self._zone = zone
- self._zone_type = zone_type
-
- @property
- def device_class(self):
- """Return the class of this sensor, from DEVICE_CLASSES."""
- return self._zone_type
+ self._attr_device_class = zone_type
@property
def name(self):
@@ -112,7 +109,7 @@ class NX584ZoneSensor(BinarySensorEntity):
return self._zone["state"]
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {
"zone_number": self._zone["number"],
diff --git a/homeassistant/components/nyt_games/strings.json b/homeassistant/components/nyt_games/strings.json
index 008e3c4612d..4ba1a5f8bec 100644
--- a/homeassistant/components/nyt_games/strings.json
+++ b/homeassistant/components/nyt_games/strings.json
@@ -8,6 +8,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py
index ddf4942ef25..920af78b4ee 100644
--- a/homeassistant/components/oasa_telematics/sensor.py
+++ b/homeassistant/components/oasa_telematics/sensor.py
@@ -2,9 +2,10 @@
from __future__ import annotations
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
from operator import itemgetter
+from typing import Any
import oasatelematics
import voluptuous as vol
@@ -55,9 +56,9 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the OASA Telematics sensor."""
- name = config[CONF_NAME]
- stop_id = config[CONF_STOP_ID]
- route_id = config.get(CONF_ROUTE_ID)
+ name: str = config[CONF_NAME]
+ stop_id: str = config[CONF_STOP_ID]
+ route_id: str = config[CONF_ROUTE_ID]
data = OASATelematicsData(stop_id, route_id)
@@ -68,42 +69,31 @@ class OASATelematicsSensor(SensorEntity):
"""Implementation of the OASA Telematics sensor."""
_attr_attribution = "Data retrieved from telematics.oasa.gr"
+ _attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:bus"
- def __init__(self, data, stop_id, route_id, name):
+ def __init__(
+ self, data: OASATelematicsData, stop_id: str, route_id: str, name: str
+ ) -> None:
"""Initialize the sensor."""
self.data = data
- self._name = name
+ self._attr_name = name
self._stop_id = stop_id
self._route_id = route_id
- self._name_data = self._times = self._state = None
+ self._name_data: dict[str, Any] | None = None
+ self._times: list[dict[str, Any]] | None = None
@property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def device_class(self):
- """Return the class of this sensor."""
- return SensorDeviceClass.TIMESTAMP
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- return self._state
-
- @property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
params = {}
if self._times is not None:
next_arrival_data = self._times[0]
if ATTR_NEXT_ARRIVAL in next_arrival_data:
- next_arrival = next_arrival_data[ATTR_NEXT_ARRIVAL]
+ next_arrival: datetime = next_arrival_data[ATTR_NEXT_ARRIVAL]
params.update({ATTR_NEXT_ARRIVAL: next_arrival.isoformat()})
if len(self._times) > 1:
- second_next_arrival_time = self._times[1][ATTR_NEXT_ARRIVAL]
+ second_next_arrival_time: datetime = self._times[1][ATTR_NEXT_ARRIVAL]
if second_next_arrival_time is not None:
second_arrival = second_next_arrival_time
params.update(
@@ -115,12 +105,13 @@ class OASATelematicsSensor(SensorEntity):
ATTR_STOP_ID: self._stop_id,
}
)
- params.update(
- {
- ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
- ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
- }
- )
+ if self._name_data is not None:
+ params.update(
+ {
+ ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
+ ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
+ }
+ )
return {k: v for k, v in params.items() if v}
def update(self) -> None:
@@ -130,7 +121,7 @@ class OASATelematicsSensor(SensorEntity):
self._name_data = self.data.name_data
next_arrival_data = self._times[0]
if ATTR_NEXT_ARRIVAL in next_arrival_data:
- self._state = next_arrival_data[ATTR_NEXT_ARRIVAL]
+ self._attr_native_value = next_arrival_data[ATTR_NEXT_ARRIVAL]
class OASATelematicsData:
diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py
index 093e20f5140..6fcc5a959df 100644
--- a/homeassistant/components/ollama/const.py
+++ b/homeassistant/components/ollama/const.py
@@ -158,7 +158,7 @@ MODEL_NAMES = [ # https://ollama.com/library
"yi",
"zephyr",
]
-DEFAULT_MODEL = "qwen3:4b"
+DEFAULT_MODEL = "qwen3:4b-instruct"
DEFAULT_CONVERSATION_NAME = "Ollama Conversation"
DEFAULT_AI_TASK_NAME = "Ollama AI Task"
diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py
index c02fbdfa01d..a851871dbbd 100644
--- a/homeassistant/components/onedrive/backup.py
+++ b/homeassistant/components/onedrive/backup.py
@@ -178,6 +178,7 @@ class OneDriveBackupAgent(BackupAgent):
file,
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
+ smart_chunk_size=True,
)
except HashMismatchError as err:
raise BackupAgentError(
diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json
index df861f99751..79be8d85b55 100644
--- a/homeassistant/components/onedrive/manifest.json
+++ b/homeassistant/components/onedrive/manifest.json
@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
- "requirements": ["onedrive-personal-sdk==0.0.17"]
+ "requirements": ["onedrive-personal-sdk==0.1.1"]
}
diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json
index 2c4db4b2fd1..25955296cc2 100644
--- a/homeassistant/components/onedrive/strings.json
+++ b/homeassistant/components/onedrive/strings.json
@@ -25,6 +25,9 @@
"folder_creation_error": "Failed to create folder",
"folder_rename_error": "Failed to rename folder"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"folder_name": {
"data": {
diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py
index 6a60e98eb87..dabe2f560f4 100644
--- a/homeassistant/components/onewire/const.py
+++ b/homeassistant/components/onewire/const.py
@@ -28,7 +28,7 @@ DEVICE_SUPPORT = {
"3A": (),
"3B": (),
"42": (),
- "7E": ("EDS0066", "EDS0068"),
+ "7E": ("EDS0065", "EDS0066", "EDS0068"),
"A6": (),
"EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"),
}
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
index f54b66b059d..b627a1d5a4d 100644
--- a/homeassistant/components/onewire/sensor.py
+++ b/homeassistant/components/onewire/sensor.py
@@ -297,6 +297,20 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
# 7E sensors are special sensors by Embedded Data Systems
EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
+ "EDS0065": (
+ OneWireSensorEntityDescription(
+ key="EDS0065/temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ OneWireSensorEntityDescription(
+ key="EDS0065/humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ ),
"EDS0066": (
OneWireSensorEntityDescription(
key="EDS0066/temperature",
diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py
index b4c9a16693a..f8e4b09f3ce 100644
--- a/homeassistant/components/openai_conversation/__init__.py
+++ b/homeassistant/components/openai_conversation/__init__.py
@@ -25,6 +25,7 @@ from homeassistant.core import (
SupportsResponse,
)
from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
@@ -96,6 +97,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
response_format="url",
n=1,
)
+ except openai.AuthenticationError as err:
+ entry.async_start_reauth(hass)
+ raise HomeAssistantError("Authentication error") from err
except openai.OpenAIError as err:
raise HomeAssistantError(f"Error generating image: {err}") from err
@@ -179,7 +183,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
response: Response = await client.responses.create(**model_args)
-
+ except openai.AuthenticationError as err:
+ entry.async_start_reauth(hass)
+ raise HomeAssistantError("Authentication error") from err
except openai.OpenAIError as err:
raise HomeAssistantError(f"Error generating content: {err}") from err
except FileNotFoundError as err:
@@ -245,8 +251,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
try:
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
except openai.AuthenticationError as err:
- LOGGER.error("Invalid API key: %s", err)
- return False
+ raise ConfigEntryAuthFailed(err) from err
except openai.OpenAIError as err:
raise ConfigEntryNotReady(err) from err
@@ -259,7 +264,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
"""Unload OpenAI."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -280,7 +285,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
if not any(entry.version == 1 for entry in entries):
return
- api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
+ api_keys_entries: dict[str, tuple[OpenAIConfigEntry, bool]] = {}
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py
index 91933a36bb9..29badc0bc82 100644
--- a/homeassistant/components/openai_conversation/ai_task.py
+++ b/homeassistant/components/openai_conversation/ai_task.py
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING
from openai.types.responses.response_output_item import ImageGenerationCall
from homeassistant.components import ai_task, conversation
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -35,7 +34,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: OpenAIConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py
index cdfd3b72cfc..5cc604ca16b 100644
--- a/homeassistant/components/openai_conversation/config_flow.py
+++ b/homeassistant/components/openai_conversation/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Mapping
import json
import logging
from typing import Any
@@ -12,6 +13,7 @@ from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
+ SOURCE_REAUTH,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
@@ -112,47 +114,72 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
- if user_input is None:
- return self.async_show_form(
- step_id="user", data_schema=STEP_USER_DATA_SCHEMA
- )
errors: dict[str, str] = {}
- self._async_abort_entries_match(user_input)
- try:
- await validate_input(self.hass, user_input)
- except openai.APIConnectionError:
- errors["base"] = "cannot_connect"
- except openai.AuthenticationError:
- errors["base"] = "invalid_auth"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
- return self.async_create_entry(
- title="ChatGPT",
- data=user_input,
- subentries=[
- {
- "subentry_type": "conversation",
- "data": RECOMMENDED_CONVERSATION_OPTIONS,
- "title": DEFAULT_CONVERSATION_NAME,
- "unique_id": None,
- },
- {
- "subentry_type": "ai_task_data",
- "data": RECOMMENDED_AI_TASK_OPTIONS,
- "title": DEFAULT_AI_TASK_NAME,
- "unique_id": None,
- },
- ],
- )
+ if user_input is not None:
+ self._async_abort_entries_match(user_input)
+ try:
+ await validate_input(self.hass, user_input)
+ except openai.APIConnectionError:
+ errors["base"] = "cannot_connect"
+ except openai.AuthenticationError:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if self.source == SOURCE_REAUTH:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data_updates=user_input
+ )
+ return self.async_create_entry(
+ title="ChatGPT",
+ data=user_input,
+ subentries=[
+ {
+ "subentry_type": "conversation",
+ "data": RECOMMENDED_CONVERSATION_OPTIONS,
+ "title": DEFAULT_CONVERSATION_NAME,
+ "unique_id": None,
+ },
+ {
+ "subentry_type": "ai_task_data",
+ "data": RECOMMENDED_AI_TASK_OPTIONS,
+ "title": DEFAULT_AI_TASK_NAME,
+ "unique_id": None,
+ },
+ ],
+ )
return self.async_show_form(
- step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA, user_input
+ ),
+ errors=errors,
+ description_placeholders={
+ "instructions_url": "https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key",
+ },
)
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ if not user_input:
+ return self.async_show_form(
+ step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA
+ )
+
+ return await self.async_step_user(user_input)
+
@classmethod
@callback
def async_get_supported_subentry_types(
diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py
index 3ba488d87db..70352952023 100644
--- a/homeassistant/components/openai_conversation/const.py
+++ b/homeassistant/components/openai_conversation/const.py
@@ -89,6 +89,8 @@ UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS: list[str] = [
"gpt-3.5",
"gpt-4-turbo",
"gpt-4o",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
"gpt-5-mini",
"gpt-5-nano",
]
diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json
index 4b870d23c30..c8e6f333e8e 100644
--- a/homeassistant/components/openai_conversation/strings.json
+++ b/homeassistant/components/openai_conversation/strings.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,10 +10,23 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
+ "reauth_confirm": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::openai_conversation::config::step::user::data_description::api_key%]"
+ },
+ "description": "Reauthentication required. Please enter your updated API key."
+ },
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
- }
+ },
+ "data_description": {
+ "api_key": "Your OpenAI API key."
+ },
+ "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})."
}
}
},
diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py
index b5043844524..b2847d8f785 100644
--- a/homeassistant/components/openevse/__init__.py
+++ b/homeassistant/components/openevse/__init__.py
@@ -4,27 +4,35 @@ from __future__ import annotations
from openevsehttp.__main__ import OpenEVSE
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, Platform
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.exceptions import ConfigEntryNotReady
-type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
+from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
- """Set up openevse from a config entry."""
+ """Set up OpenEVSE from a config entry."""
+ charger = OpenEVSE(
+ entry.data[CONF_HOST],
+ entry.data.get(CONF_USERNAME),
+ entry.data.get(CONF_PASSWORD),
+ )
- entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
try:
- await entry.runtime_data.test_and_get()
+ await charger.test_and_get()
except TimeoutError as ex:
- raise ConfigEntryError("Unable to connect to charger") from ex
+ raise ConfigEntryNotReady("Unable to connect to charger") from ex
+
+ coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR])
diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py
index 5eb2b775aff..129de7635fc 100644
--- a/homeassistant/components/openevse/config_flow.py
+++ b/homeassistant/components/openevse/config_flow.py
@@ -3,14 +3,22 @@
from typing import Any
from openevsehttp.__main__ import OpenEVSE
+from openevsehttp.exceptions import AuthenticationError, MissingSerial
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info import zeroconf
from .const import CONF_ID, CONF_SERIAL, DOMAIN
+USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string})
+
+AUTH_SCHEMA = vol.Schema(
+ {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
+)
+
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""OpenEVSE config flow."""
@@ -21,39 +29,49 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
+ self._host: str | None = None
- async def check_status(self, host: str) -> tuple[bool, str | None]:
+ async def check_status(
+ self, host: str, user: str | None = None, password: str | None = None
+ ) -> tuple[dict[str, str], str | None]:
"""Check if we can connect to the OpenEVSE charger."""
- charger = OpenEVSE(host)
+ charger = OpenEVSE(host, user, password)
try:
result = await charger.test_and_get()
except TimeoutError:
- return False, None
- return True, result.get(CONF_SERIAL)
+ return {"base": "cannot_connect"}, None
+ except AuthenticationError:
+ return {"base": "invalid_auth"}, None
+ except MissingSerial:
+ return {}, None
+ return {}, result.get(CONF_SERIAL)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
- errors = {}
+ errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ errors, serial = await self.check_status(user_input[CONF_HOST])
- if (result := await self.check_status(user_input[CONF_HOST]))[0]:
- if (serial := result[1]) is not None:
+ if not errors:
+ if serial is not None:
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
)
- errors = {CONF_HOST: "cannot_connect"}
+ if errors["base"] == "invalid_auth":
+ self._host = user_input[CONF_HOST]
+ return await self.async_step_auth()
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
+ data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input),
errors=errors,
)
@@ -61,9 +79,10 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
+ errors, serial = await self.check_status(data[CONF_HOST])
- if (result := await self.check_status(data[CONF_HOST]))[0]:
- if (serial := result[1]) is not None:
+ if not errors:
+ if serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
else:
@@ -92,17 +111,20 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
self.context.update({"title_placeholders": {"name": name}})
-
- if not (await self.check_status(host))[0]:
- return self.async_abort(reason="cannot_connect")
-
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
+ errors, _ = await self.check_status(self.discovery_info[CONF_HOST])
+ if errors:
+ if errors["base"] == "invalid_auth":
+ return await self.async_step_auth()
+ return self.async_abort(reason="unavailable_host")
+
if user_input is None:
+ self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
@@ -112,3 +134,36 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.discovery_info[CONF_NAME],
data={CONF_HOST: self.discovery_info[CONF_HOST]},
)
+
+ async def async_step_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the authentication step."""
+
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ host = self._host or self.discovery_info[CONF_HOST]
+ errors, serial = await self.check_status(
+ host,
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ )
+
+ if not errors:
+ if self.unique_id is None and serial is not None:
+ await self.async_set_unique_id(serial)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=f"OpenEVSE {host}",
+ data={
+ CONF_HOST: host,
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="auth",
+ data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input),
+ errors=errors,
+ )
diff --git a/homeassistant/components/openevse/coordinator.py b/homeassistant/components/openevse/coordinator.py
new file mode 100644
index 00000000000..84bed399a65
--- /dev/null
+++ b/homeassistant/components/openevse/coordinator.py
@@ -0,0 +1,51 @@
+"""Data update coordinator for OpenEVSE."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from openevsehttp.__main__ import OpenEVSE
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+type OpenEVSEConfigEntry = ConfigEntry[OpenEVSEDataUpdateCoordinator]
+
+
+class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
+ """Class to manage fetching OpenEVSE data."""
+
+ config_entry: OpenEVSEConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: OpenEVSEConfigEntry,
+ charger: OpenEVSE,
+ ) -> None:
+ """Initialize coordinator."""
+ self.charger = charger
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from OpenEVSE charger."""
+ try:
+ await self.charger.update()
+ except TimeoutError as error:
+ raise UpdateFailed(
+ f"Timeout communicating with charger: {error}"
+ ) from error
diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py
index 330f1eeec82..6321c70e22c 100644
--- a/homeassistant/components/openevse/sensor.py
+++ b/homeassistant/components/openevse/sensor.py
@@ -2,13 +2,14 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
import logging
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.components.sensor import (
- DOMAIN as HOMEASSISTANT_DOMAIN,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
@@ -25,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -33,61 +34,82 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import ConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE
+from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
-SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
- SensorEntityDescription(
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class OpenEVSESensorDescription(SensorEntityDescription):
+ """Describes an OpenEVSE sensor entity."""
+
+ value_fn: Callable[[OpenEVSE], str | float | None]
+
+
+SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = (
+ OpenEVSESensorDescription(
key="status",
translation_key="status",
+ value_fn=lambda ev: ev.status,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="charge_time",
translation_key="charge_time",
- native_unit_of_measurement=UnitOfTime.MINUTES,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda ev: ev.charge_time_elapsed,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="ambient_temp",
translation_key="ambient_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda ev: ev.ambient_temperature,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="ir_temp",
translation_key="ir_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda ev: ev.ir_temperature,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="rtc_temp",
translation_key="rtc_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda ev: ev.rtc_temperature,
entity_registry_enabled_default=False,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="usage_session",
translation_key="usage_session",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda ev: ev.usage_session,
),
- SensorEntityDescription(
+ OpenEVSESensorDescription(
key="usage_total",
translation_key="usage_total",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda ev: ev.usage_total,
),
)
@@ -154,41 +176,34 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add sensors for passed config_entry in HA."""
+ """Set up OpenEVSE sensors based on config entry."""
+ coordinator = entry.runtime_data
+ identifier = entry.unique_id or entry.entry_id
async_add_entities(
- (
- OpenEVSESensor(
- config_entry.runtime_data,
- description,
- config_entry.entry_id,
- config_entry.unique_id,
- )
- for description in SENSOR_TYPES
- ),
- True,
+ OpenEVSESensor(coordinator, description, identifier, entry.unique_id)
+ for description in SENSOR_TYPES
)
-class OpenEVSESensor(SensorEntity):
+class OpenEVSESensor(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SensorEntity):
"""Implementation of an OpenEVSE sensor."""
_attr_has_entity_name = True
+ entity_description: OpenEVSESensorDescription
def __init__(
self,
- charger: OpenEVSE,
- description: SensorEntityDescription,
- entry_id: str,
+ coordinator: OpenEVSEDataUpdateCoordinator,
+ description: OpenEVSESensorDescription,
+ identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
+ super().__init__(coordinator)
self.entity_description = description
- self.charger = charger
-
- identifier = unique_id or entry_id
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
@@ -201,28 +216,7 @@ class OpenEVSESensor(SensorEntity):
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
- async def async_update(self) -> None:
- """Get the monitored data from the charger."""
- try:
- await self.charger.update()
- except TimeoutError:
- _LOGGER.warning("Could not update status for %s", self.name)
- return
-
- sensor_type = self.entity_description.key
- if sensor_type == "status":
- self._attr_native_value = self.charger.status
- elif sensor_type == "charge_time":
- self._attr_native_value = self.charger.charge_time_elapsed / 60
- elif sensor_type == "ambient_temp":
- self._attr_native_value = self.charger.ambient_temperature
- elif sensor_type == "ir_temp":
- self._attr_native_value = self.charger.ir_temperature
- elif sensor_type == "rtc_temp":
- self._attr_native_value = self.charger.rtc_temperature
- elif sensor_type == "usage_session":
- self._attr_native_value = float(self.charger.usage_session) / 1000
- elif sensor_type == "usage_total":
- self._attr_native_value = float(self.charger.usage_total) / 1000
- else:
- self._attr_native_value = "Unknown"
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.charger)
diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json
index 593b1ff1bbc..f3f75f541d8 100644
--- a/homeassistant/components/openevse/strings.json
+++ b/homeassistant/components/openevse/strings.json
@@ -5,9 +5,20 @@
"unavailable_host": "Unable to connect to host"
},
"error": {
- "cannot_connect": "Unable to connect"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
+ "auth": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "username": "[%key:common::config_flow::data::username%]"
+ },
+ "data_description": {
+ "password": "The password to access your OpenEVSE charger",
+ "username": "The username to access your OpenEVSE charger"
+ }
+ },
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -44,6 +55,10 @@
}
},
"issues": {
+ "deprecated_yaml_import_issue_unavailable_host": {
+ "description": "Configuring {integration_title} using YAML is being removed but there was a connection error while trying to import the YAML configuration.\n\nEnsure your OpenEVSE charger is accessible and restart Home Assistant to try again.",
+ "title": "The {integration_title} YAML configuration import failed"
+ },
"yaml_deprecated": {
"description": "Configuring OpenEVSE using YAML is being removed. Your existing YAML configuration has been imported into the UI automatically. Remove the `openevse` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "OpenEVSE YAML configuration is deprecated"
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index 036e4c2b973..9c9b55920a7 100644
--- a/homeassistant/components/opower/manifest.json
+++ b/homeassistant/components/opower/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
- "requirements": ["opower==0.16.1"]
+ "requirements": ["opower==0.16.4"]
}
diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
index a24e3abd732..fb449f4bbd3 100644
--- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
+++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py
@@ -128,15 +128,15 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
states = self.device.states
- if (
- operating_mode := states[OverkizState.CORE_OPERATING_MODE]
- ) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL:
- return PRESET_EXTERNAL
-
if (
state := states[OverkizState.IO_TARGET_HEATING_LEVEL]
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
+
+ if (
+ operating_mode := states[OverkizState.CORE_OPERATING_MODE]
+ ) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL:
+ return PRESET_EXTERNAL
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json
index 81da21b2a54..ade83e87473 100644
--- a/homeassistant/components/owntracks/manifest.json
+++ b/homeassistant/components/owntracks/manifest.json
@@ -9,6 +9,6 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["nacl"],
- "requirements": ["PyNaCl==1.6.0"],
+ "requirements": ["PyNaCl==1.6.2"],
"single_config_entry": true
}
diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json
index 075e190bd71..65f448a4f39 100644
--- a/homeassistant/components/playstation_network/strings.json
+++ b/homeassistant/components/playstation_network/strings.json
@@ -13,6 +13,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"reauth_confirm": {
"data": {
diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py
index 26176cb65a4..a670f04de40 100644
--- a/homeassistant/components/point/__init__.py
+++ b/homeassistant/components/point/__init__.py
@@ -7,7 +7,6 @@ from aiohttp import ClientError, ClientResponseError, web
from pypoint import PointSession
from homeassistant.components import webhook
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -21,14 +20,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from . import api
from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK
-from .coordinator import PointDataUpdateCoordinator
+from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
-type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
-
async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
"""Set up Minut Point from a config entry."""
@@ -59,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo
point_session = PointSession(auth)
- coordinator = PointDataUpdateCoordinator(hass, point_session)
+ coordinator = PointDataUpdateCoordinator(hass, point_session, entry)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py
index 2df26283624..5902b77076d 100644
--- a/homeassistant/components/point/alarm_control_panel.py
+++ b/homeassistant/components/point/alarm_control_panel.py
@@ -16,8 +16,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import PointConfigEntry
from .const import DOMAIN, SIGNAL_WEBHOOK
+from .coordinator import PointConfigEntry
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py
index 17fe40b9654..8113899b505 100644
--- a/homeassistant/components/point/binary_sensor.py
+++ b/homeassistant/components/point/binary_sensor.py
@@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import PointConfigEntry
from .const import SIGNAL_WEBHOOK
-from .coordinator import PointDataUpdateCoordinator
+from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py
index 93bd74955ea..91e4a28c8ac 100644
--- a/homeassistant/components/point/coordinator.py
+++ b/homeassistant/components/point/coordinator.py
@@ -7,6 +7,7 @@ from typing import Any
from pypoint import PointSession
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import parse_datetime
@@ -15,17 +16,24 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
+type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
+
class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Class to manage fetching Point data from the API."""
- def __init__(self, hass: HomeAssistant, point: PointSession) -> None:
+ config_entry: PointConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, point: PointSession, config_entry: PointConfigEntry
+ ) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
+ config_entry=config_entry,
)
self.point = point
self.device_updates: dict[str, datetime] = {}
diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py
index 246536d86ab..40b59b17575 100644
--- a/homeassistant/components/point/sensor.py
+++ b/homeassistant/components/point/sensor.py
@@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from . import PointConfigEntry
-from .coordinator import PointDataUpdateCoordinator
+from .coordinator import PointConfigEntry, PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/pooldose/entity.py b/homeassistant/components/pooldose/entity.py
index 4965700ce1b..013e28751c3 100644
--- a/homeassistant/components/pooldose/entity.py
+++ b/homeassistant/components/pooldose/entity.py
@@ -2,7 +2,8 @@
from __future__ import annotations
-from typing import Literal
+from collections.abc import Callable, Coroutine
+from typing import Any, Literal
from pooldose.type_definitions import DeviceInfoDict, ValueDict
@@ -80,7 +81,10 @@ class PooldoseEntity(CoordinatorEntity[PooldoseCoordinator]):
return platform_data.get(self.entity_description.key)
async def _async_perform_write(
- self, api_call, key: str, value: bool | str | float
+ self,
+ api_call: Callable[[str, Any], Coroutine[Any, Any, bool]],
+ key: str,
+ value: bool | str | float,
) -> None:
"""Perform a write call to the API with unified error handling.
diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json
index 0fd48657a61..87610c605c2 100644
--- a/homeassistant/components/pooldose/manifest.json
+++ b/homeassistant/components/pooldose/manifest.json
@@ -11,6 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "silver",
- "requirements": ["python-pooldose==0.8.1"]
+ "quality_scale": "platinum",
+ "requirements": ["python-pooldose==0.8.2"]
}
diff --git a/homeassistant/components/pooldose/quality_scale.yaml b/homeassistant/components/pooldose/quality_scale.yaml
index 6004d7fdb14..3de73acc596 100644
--- a/homeassistant/components/pooldose/quality_scale.yaml
+++ b/homeassistant/components/pooldose/quality_scale.yaml
@@ -45,12 +45,12 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: done
- docs-examples: todo
+ docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
- docs-use-cases: todo
+ docs-use-cases: done
dynamic-devices:
status: exempt
comment: This integration does not support dynamic device discovery, as each config entry represents a single PoolDose device with all available entities.
@@ -71,4 +71,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
- strict-typing: todo
+ strict-typing: done
diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json
index fa15be5a5a1..0f35f2b68f8 100644
--- a/homeassistant/components/portainer/manifest.json
+++ b/homeassistant/components/portainer/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["pyportainer==1.0.22"]
+ "requirements": ["pyportainer==1.0.23"]
}
diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py
index 79202159685..273f984c35f 100644
--- a/homeassistant/components/portainer/switch.py
+++ b/homeassistant/components/portainer/switch.py
@@ -40,12 +40,13 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription):
async def perform_action(
action: str, portainer: Portainer, endpoint_id: int, container_id: str
) -> None:
- """Stop a container."""
+ """Perform an action on a container."""
try:
- if action == "start":
- await portainer.start_container(endpoint_id, container_id)
- elif action == "stop":
- await portainer.stop_container(endpoint_id, container_id)
+ match action:
+ case "start":
+ await portainer.start_container(endpoint_id, container_id)
+ case "stop":
+ await portainer.stop_container(endpoint_id, container_id)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json
index 70cdc0844e7..deac43d1657 100644
--- a/homeassistant/components/prowl/manifest.json
+++ b/homeassistant/components/prowl/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["prowl"],
- "quality_scale": "legacy",
"requirements": ["prowlpy==1.1.1"]
}
diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py
index 00b39957984..ed9652c55c6 100644
--- a/homeassistant/components/proxmoxve/__init__.py
+++ b/homeassistant/components/proxmoxve/__init__.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import timedelta
+import logging
from typing import Any
from proxmoxer import AuthenticationError, ProxmoxAPI
@@ -10,6 +11,7 @@ import requests.exceptions
from requests.exceptions import ConnectTimeout, SSLError
import voluptuous as vol
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -18,26 +20,29 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
Platform,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm
+from .common import (
+ ProxmoxClient,
+ ResourceException,
+ call_api_container_vm,
+ parse_api_container_vm,
+)
from .const import (
- _LOGGER,
CONF_CONTAINERS,
CONF_NODE,
CONF_NODES,
CONF_REALM,
CONF_VMS,
- COORDINATORS,
DEFAULT_PORT,
DEFAULT_REALM,
DEFAULT_VERIFY_SSL,
DOMAIN,
- PROXMOX_CLIENTS,
TYPE_CONTAINER,
TYPE_VM,
UPDATE_INTERVAL,
@@ -45,6 +50,10 @@ from .const import (
PLATFORMS = [Platform.BINARY_SENSOR]
+type ProxmoxConfigEntry = ConfigEntry[
+ dict[str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]]
+]
+
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
@@ -84,109 +93,154 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
+LOGGER = logging.getLogger(__name__)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the platform."""
- hass.data.setdefault(DOMAIN, {})
+ """Import the Proxmox configuration from YAML."""
+ if DOMAIN not in config:
+ return True
- def build_client() -> ProxmoxAPI:
- """Build the Proxmox client connection."""
- hass.data[PROXMOX_CLIENTS] = {}
-
- for entry in config[DOMAIN]:
- host = entry[CONF_HOST]
- port = entry[CONF_PORT]
- user = entry[CONF_USERNAME]
- realm = entry[CONF_REALM]
- password = entry[CONF_PASSWORD]
- verify_ssl = entry[CONF_VERIFY_SSL]
-
- hass.data[PROXMOX_CLIENTS][host] = None
-
- try:
- # Construct an API client with the given data for the given host
- proxmox_client = ProxmoxClient(
- host, port, user, realm, password, verify_ssl
- )
- proxmox_client.build_client()
- except AuthenticationError:
- _LOGGER.warning(
- "Invalid credentials for proxmox instance %s:%d", host, port
- )
- continue
- except SSLError:
- _LOGGER.error(
- (
- "Unable to verify proxmox server SSL. "
- 'Try using "verify_ssl: false" for proxmox instance %s:%d'
- ),
- host,
- port,
- )
- continue
- except ConnectTimeout:
- _LOGGER.warning("Connection to host %s timed out during setup", host)
- continue
- except requests.exceptions.ConnectionError:
- _LOGGER.warning("Host %s is not reachable", host)
- continue
-
- hass.data[PROXMOX_CLIENTS][host] = proxmox_client
-
- await hass.async_add_executor_job(build_client)
-
- coordinators: dict[
- str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]
- ] = {}
- hass.data[DOMAIN][COORDINATORS] = coordinators
-
- # Create a coordinator for each vm/container
- for host_config in config[DOMAIN]:
- host_name = host_config["host"]
- coordinators[host_name] = {}
-
- proxmox_client = hass.data[PROXMOX_CLIENTS][host_name]
-
- # Skip invalid hosts
- if proxmox_client is None:
- continue
-
- proxmox = proxmox_client.get_api_client()
-
- for node_config in host_config["nodes"]:
- node_name = node_config["node"]
- node_coordinators = coordinators[host_name][node_name] = {}
-
- for vm_id in node_config["vms"]:
- coordinator = create_coordinator_container_vm(
- hass, proxmox, host_name, node_name, vm_id, TYPE_VM
- )
-
- # Fetch initial data
- await coordinator.async_refresh()
-
- node_coordinators[vm_id] = coordinator
-
- for container_id in node_config["containers"]:
- coordinator = create_coordinator_container_vm(
- hass, proxmox, host_name, node_name, container_id, TYPE_CONTAINER
- )
-
- # Fetch initial data
- await coordinator.async_refresh()
-
- node_coordinators[container_id] = coordinator
-
- for component in PLATFORMS:
- await hass.async_create_task(
- async_load_platform(hass, component, DOMAIN, {"config": config}, config)
- )
+ hass.async_create_task(_async_setup(hass, config))
return True
-def create_coordinator_container_vm(
+async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
+ for entry_config in config[DOMAIN]:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=entry_config,
+ )
+ if (
+ result.get("type") is FlowResultType.ABORT
+ and result.get("reason") != "already_configured"
+ ):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_yaml_import_issue_{result.get('reason')}",
+ breaks_in_ha_version="2026.8.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Proxmox VE",
+ },
+ )
+ return
+
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2026.8.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Proxmox VE",
+ },
+ )
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
+ """Set up a ProxmoxVE instance from a config entry."""
+
+ def build_client() -> ProxmoxClient:
+ """Build and return the Proxmox client connection."""
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+ user = entry.data[CONF_USERNAME]
+ realm = entry.data[CONF_REALM]
+ password = entry.data[CONF_PASSWORD]
+ verify_ssl = entry.data[CONF_VERIFY_SSL]
+ try:
+ client = ProxmoxClient(host, port, user, realm, password, verify_ssl)
+ client.build_client()
+ except AuthenticationError as ex:
+ raise ConfigEntryAuthFailed("Invalid credentials") from ex
+ except SSLError as ex:
+ raise ConfigEntryAuthFailed(
+ f"Unable to verify proxmox server SSL. Try using 'verify_ssl: false' for proxmox instance {host}:{port}"
+ ) from ex
+ except ConnectTimeout as ex:
+ raise ConfigEntryNotReady("Connection timed out") from ex
+ except requests.exceptions.ConnectionError as ex:
+ raise ConfigEntryNotReady(f"Host {host} is not reachable: {ex}") from ex
+ else:
+ return client
+
+ proxmox_client = await hass.async_add_executor_job(build_client)
+
+ coordinators: dict[
+ str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]
+ ] = {}
+ entry.runtime_data = coordinators
+
+ host_name = entry.data[CONF_HOST]
+ coordinators[host_name] = {}
+
+ proxmox: ProxmoxAPI = proxmox_client.get_api_client()
+
+ for node_config in entry.data[CONF_NODES]:
+ node_name = node_config[CONF_NODE]
+ node_coordinators = coordinators[host_name][node_name] = {}
+
+ try:
+ vms, containers = await hass.async_add_executor_job(
+ _get_vms_containers, proxmox, node_config
+ )
+ except (ResourceException, requests.exceptions.ConnectionError) as err:
+ LOGGER.error("Unable to get vms/containers for node %s: %s", node_name, err)
+ continue
+
+ for vm in vms:
+ coordinator = _create_coordinator_container_vm(
+ hass, entry, proxmox, host_name, node_name, vm["vmid"], TYPE_VM
+ )
+ await coordinator.async_config_entry_first_refresh()
+
+ node_coordinators[vm["vmid"]] = coordinator
+
+ for container in containers:
+ coordinator = _create_coordinator_container_vm(
+ hass,
+ entry,
+ proxmox,
+ host_name,
+ node_name,
+ container["vmid"],
+ TYPE_CONTAINER,
+ )
+ await coordinator.async_config_entry_first_refresh()
+
+ node_coordinators[container["vmid"]] = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+def _get_vms_containers(
+ proxmox: ProxmoxAPI,
+ node_config: dict[str, Any],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Get vms and containers for a node."""
+ vms = proxmox.nodes(node_config[CONF_NODE]).qemu.get()
+ containers = proxmox.nodes(node_config[CONF_NODE]).lxc.get()
+ assert vms is not None and containers is not None
+ return vms, containers
+
+
+def _create_coordinator_container_vm(
hass: HomeAssistant,
+ entry: ProxmoxConfigEntry,
proxmox: ProxmoxAPI,
host_name: str,
node_name: str,
@@ -205,7 +259,7 @@ def create_coordinator_container_vm(
vm_status = await hass.async_add_executor_job(poll_api)
if vm_status is None:
- _LOGGER.warning(
+ LOGGER.warning(
"Vm/Container %s unable to be found in node %s", vm_id, node_name
)
return None
@@ -214,9 +268,14 @@ def create_coordinator_container_vm(
return DataUpdateCoordinator(
hass,
- _LOGGER,
- config_entry=None,
+ LOGGER,
+ config_entry=entry,
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py
index 412f40af6e8..abc3ced24f0 100644
--- a/homeassistant/components/proxmoxve/binary_sensor.py
+++ b/homeassistant/components/proxmoxve/binary_sensor.py
@@ -2,55 +2,48 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS
+from . import ProxmoxConfigEntry
+from .const import CONF_CONTAINERS, CONF_NODE, CONF_NODES, CONF_VMS
from .entity import ProxmoxEntity
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ entry: ProxmoxConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
- if discovery_info is None:
- return
-
sensors = []
- for host_config in discovery_info["config"][DOMAIN]:
- host_name = host_config["host"]
- host_name_coordinators = hass.data[DOMAIN][COORDINATORS][host_name]
+ host_name = entry.data[CONF_HOST]
+ host_name_coordinators = entry.runtime_data[host_name]
- if hass.data[PROXMOX_CLIENTS][host_name] is None:
- continue
+ for node_config in entry.data[CONF_NODES]:
+ node_name = node_config[CONF_NODE]
- for node_config in host_config["nodes"]:
- node_name = node_config["node"]
+ for dev_id in node_config[CONF_VMS] + node_config[CONF_CONTAINERS]:
+ coordinator = host_name_coordinators[node_name][dev_id]
- for dev_id in node_config["vms"] + node_config["containers"]:
- coordinator = host_name_coordinators[node_name][dev_id]
+ if TYPE_CHECKING:
+ assert coordinator.data is not None
+ name = coordinator.data["name"]
+ sensor = create_binary_sensor(
+ coordinator, host_name, node_name, dev_id, name
+ )
+ sensors.append(sensor)
- # unfound case
- if (coordinator_data := coordinator.data) is None:
- continue
-
- name = coordinator_data["name"]
- sensor = create_binary_sensor(
- coordinator, host_name, node_name, dev_id, name
- )
- sensors.append(sensor)
-
- add_entities(sensors)
+ async_add_entities(sensors)
def create_binary_sensor(
diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py
new file mode 100644
index 00000000000..74b6a74722a
--- /dev/null
+++ b/homeassistant/components/proxmoxve/config_flow.py
@@ -0,0 +1,175 @@
+"""Config flow for Proxmox VE integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from proxmoxer import AuthenticationError, ProxmoxAPI
+import requests
+from requests.exceptions import ConnectTimeout, SSLError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+
+from .common import ResourceException
+from .const import (
+ CONF_CONTAINERS,
+ CONF_NODE,
+ CONF_NODES,
+ CONF_REALM,
+ CONF_VMS,
+ DEFAULT_PORT,
+ DEFAULT_REALM,
+ DEFAULT_VERIFY_SSL,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_REALM, default=DEFAULT_REALM): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ }
+)
+
+
+def _sanitize_userid(data: dict[str, Any]) -> str:
+ """Sanitize the user ID."""
+ return (
+ data[CONF_USERNAME]
+ if "@" in data[CONF_USERNAME]
+ else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
+ )
+
+
+def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
+ """Validate the user input and fetch data (sync, for executor)."""
+ try:
+ client = ProxmoxAPI(
+ data[CONF_HOST],
+ port=data[CONF_PORT],
+ user=_sanitize_userid(data),
+ password=data[CONF_PASSWORD],
+ verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
+ )
+ nodes = client.nodes.get()
+ except AuthenticationError as err:
+ raise ProxmoxAuthenticationError from err
+ except SSLError as err:
+ raise ProxmoxSSLError from err
+ except ConnectTimeout as err:
+ raise ProxmoxConnectTimeout from err
+ except (ResourceException, requests.exceptions.ConnectionError) as err:
+ raise ProxmoxNoNodesFound from err
+
+ _LOGGER.debug("Proxmox nodes: %s", nodes)
+
+ nodes_data: list[dict[str, Any]] = []
+ for node in nodes:
+ try:
+ vms = client.nodes(node["node"]).qemu.get()
+ containers = client.nodes(node["node"]).lxc.get()
+ except (ResourceException, requests.exceptions.ConnectionError) as err:
+ raise ProxmoxNoNodesFound from err
+
+ nodes_data.append(
+ {
+ CONF_NODE: node["node"],
+ CONF_VMS: [vm["vmid"] for vm in vms],
+ CONF_CONTAINERS: [container["vmid"] for container in containers],
+ }
+ )
+
+ _LOGGER.debug("Nodes with data: %s", nodes_data)
+ return nodes_data
+
+
+class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Proxmox VE."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ proxmox_nodes: list[dict[str, Any]] = []
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ try:
+ proxmox_nodes = await self.hass.async_add_executor_job(
+ _get_nodes_data, user_input
+ )
+ except ProxmoxConnectTimeout:
+ errors["base"] = "connect_timeout"
+ except ProxmoxAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except ProxmoxSSLError:
+ errors["base"] = "ssl_error"
+ except ProxmoxNoNodesFound:
+ errors["base"] = "no_nodes_found"
+
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data={**user_input, CONF_NODES: proxmox_nodes},
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=CONFIG_SCHEMA,
+ errors=errors,
+ )
+
+ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
+ """Handle a flow initiated by configuration file."""
+ self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
+
+ try:
+ proxmox_nodes = await self.hass.async_add_executor_job(
+ _get_nodes_data, import_data
+ )
+ except ProxmoxConnectTimeout:
+ return self.async_abort(reason="connect_timeout")
+ except ProxmoxAuthenticationError:
+ return self.async_abort(reason="invalid_auth")
+ except ProxmoxSSLError:
+ return self.async_abort(reason="ssl_error")
+ except ProxmoxNoNodesFound:
+ return self.async_abort(reason="no_nodes_found")
+
+ return self.async_create_entry(
+ title=import_data[CONF_HOST],
+ data={**import_data, CONF_NODES: proxmox_nodes},
+ )
+
+
+class ProxmoxNoNodesFound(HomeAssistantError):
+ """Error to indicate no nodes found."""
+
+
+class ProxmoxConnectTimeout(HomeAssistantError):
+ """Error to indicate a connection timeout."""
+
+
+class ProxmoxSSLError(HomeAssistantError):
+ """Error to indicate an SSL error."""
+
+
+class ProxmoxAuthenticationError(HomeAssistantError):
+ """Error to indicate an authentication error."""
diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py
index 6477c081463..da62f89069a 100644
--- a/homeassistant/components/proxmoxve/const.py
+++ b/homeassistant/components/proxmoxve/const.py
@@ -1,16 +1,12 @@
"""Constants for ProxmoxVE."""
-import logging
-
DOMAIN = "proxmoxve"
-PROXMOX_CLIENTS = "proxmox_clients"
CONF_REALM = "realm"
CONF_NODE = "node"
CONF_NODES = "nodes"
CONF_VMS = "vms"
CONF_CONTAINERS = "containers"
-COORDINATORS = "coordinators"
DEFAULT_PORT = 8006
DEFAULT_REALM = "pam"
@@ -18,5 +14,3 @@ DEFAULT_VERIFY_SSL = True
TYPE_VM = 0
TYPE_CONTAINER = 1
UPDATE_INTERVAL = 60
-
-_LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json
index 45ead1330e2..35aad8b9b88 100644
--- a/homeassistant/components/proxmoxve/manifest.json
+++ b/homeassistant/components/proxmoxve/manifest.json
@@ -1,8 +1,10 @@
{
"domain": "proxmoxve",
"name": "Proxmox VE",
- "codeowners": ["@jhollowe", "@Corbeno"],
+ "codeowners": ["@jhollowe", "@Corbeno", "@erwindouna"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/proxmoxve",
+ "integration_type": "service",
"iot_class": "local_polling",
"loggers": ["proxmoxer"],
"quality_scale": "legacy",
diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json
new file mode 100644
index 00000000000..842784306b2
--- /dev/null
+++ b/homeassistant/components/proxmoxve/strings.json
@@ -0,0 +1,46 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "error": {
+ "cannot_connect": "Cannot connect to Proxmox VE server",
+ "connect_timeout": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "no_nodes_found": "No active nodes found",
+ "ssl_error": "SSL check failed. Check the SSL settings"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "realm": "Realm",
+ "username": "[%key:common::config_flow::data::username%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "description": "Enter your Proxmox VE server details to set up the integration.",
+ "title": "Connect to Proxmox VE"
+ }
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_connect_timeout": {
+ "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
+ "title": "The {integration_title} YAML configuration is being removed"
+ },
+ "deprecated_yaml_import_issue_invalid_auth": {
+ "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
+ "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
+ },
+ "deprecated_yaml_import_issue_no_nodes_found": {
+ "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no active nodes were found on the Proxmox VE server. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
+ "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
+ },
+ "deprecated_yaml_import_issue_ssl_error": {
+ "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
+ "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
+ }
+ }
+}
diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py
index 48e7bf92d0f..59d5929cb17 100644
--- a/homeassistant/components/ps4/__init__.py
+++ b/homeassistant/components/ps4/__init__.py
@@ -126,9 +126,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"media_player",
DOMAIN,
unique_id,
- suggested_object_id=new_id,
config_entry=entry,
device_id=e_entry.device_id,
+ object_id_base=new_id,
)
_LOGGER.debug(
"PlayStation 4 identifier for entity: %s has changed",
diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py
index 4d6f7898a5a..1810bbc68aa 100644
--- a/homeassistant/components/pushsafer/notify.py
+++ b/homeassistant/components/pushsafer/notify.py
@@ -6,6 +6,7 @@ import base64
from http import HTTPStatus
import logging
import mimetypes
+from typing import Any
import requests
from requests.auth import HTTPBasicAuth
@@ -65,26 +66,23 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> PushsaferNotificationService:
"""Get the Pushsafer.com notification service."""
- return PushsaferNotificationService(
- config.get(CONF_DEVICE_KEY), hass.config.is_allowed_path
- )
+ return PushsaferNotificationService(config[CONF_DEVICE_KEY])
class PushsaferNotificationService(BaseNotificationService):
"""Implementation of the notification service for Pushsafer.com."""
- def __init__(self, private_key, is_allowed_path):
+ def __init__(self, private_key: str) -> None:
"""Initialize the service."""
self._private_key = private_key
- self.is_allowed_path = is_allowed_path
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to specified target."""
- if kwargs.get(ATTR_TARGET) is None:
+ targets: list[str] | None
+ if (targets := kwargs.get(ATTR_TARGET)) is None:
targets = ["a"]
_LOGGER.debug("No target specified. Sending push to all")
else:
- targets = kwargs.get(ATTR_TARGET)
_LOGGER.debug("%s target(s) specified", len(targets))
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
@@ -170,7 +168,7 @@ class PushsaferNotificationService(BaseNotificationService):
try:
if local_path is not None:
_LOGGER.debug("Loading image from local path")
- if self.is_allowed_path(local_path):
+ if self.hass.config.is_allowed_path(local_path):
file_mimetype = mimetypes.guess_type(local_path)
_LOGGER.debug("Detected mimetype %s", file_mimetype)
with open(local_path, "rb") as binary_file:
diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json
index 454e84c1867..ba62d35dede 100644
--- a/homeassistant/components/qingping/manifest.json
+++ b/homeassistant/components/qingping/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/qingping",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["qingping-ble==1.0.1"]
+ "requirements": ["qingping-ble==1.1.0"]
}
diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json
index d5370c26fcf..d7d5c2545e2 100644
--- a/homeassistant/components/qnap_qsw/manifest.json
+++ b/homeassistant/components/qnap_qsw/manifest.json
@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
+ "integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.4.2"]
diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py
index d3cf2ff3d9b..7dedee04508 100644
--- a/homeassistant/components/qwikswitch/__init__.py
+++ b/homeassistant/components/qwikswitch/__init__.py
@@ -24,9 +24,9 @@ from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
-_LOGGER = logging.getLogger(__name__)
+from .const import DATA_QUIKSWITCH, DOMAIN
-DOMAIN = "qwikswitch"
+_LOGGER = logging.getLogger(__name__)
CONF_DIMMER_ADJUST = "dimmer_adjust"
CONF_BUTTON_EVENTS = "button_events"
@@ -96,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not await qsusb.update_from_devices():
return False
- hass.data[DOMAIN] = qsusb
+ hass.data[DATA_QUIKSWITCH] = qsusb
comps: dict[Platform, list] = {
Platform.SWITCH: [],
@@ -168,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def async_stop(_):
"""Stop the listener."""
- hass.data[DOMAIN].stop()
+ hass.data[DATA_QUIKSWITCH].stop()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop)
diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py
index bbe8d309e50..25a9917297e 100644
--- a/homeassistant/components/qwikswitch/binary_sensor.py
+++ b/homeassistant/components/qwikswitch/binary_sensor.py
@@ -3,15 +3,19 @@
from __future__ import annotations
import logging
+from typing import Any
from pyqwikswitch.qwikswitch import SENSORS
-from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN
+from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSEntity
_LOGGER = logging.getLogger(__name__)
@@ -27,7 +31,7 @@ async def async_setup_platform(
if discovery_info is None:
return
- qsusb = hass.data[DOMAIN]
+ qsusb = hass.data[DATA_QUIKSWITCH]
_LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", qsusb, discovery_info)
devs = [QSBinarySensor(sensor) for sensor in discovery_info[DOMAIN]]
add_entities(devs)
@@ -36,9 +40,7 @@ async def async_setup_platform(
class QSBinarySensor(QSEntity, BinarySensorEntity):
"""Sensor based on a Qwikswitch relay/dimmer module."""
- _val = False
-
- def __init__(self, sensor):
+ def __init__(self, sensor: dict[str, Any]) -> None:
"""Initialize the sensor."""
super().__init__(sensor["id"], sensor["name"])
@@ -47,7 +49,9 @@ class QSBinarySensor(QSEntity, BinarySensorEntity):
self._decode, _ = SENSORS[sensor_type]
self._invert = not sensor.get("invert", False)
- self._class = sensor.get("class", "door")
+ self._attr_is_on = not self._invert
+ self._attr_device_class = sensor.get("class", BinarySensorDeviceClass.DOOR)
+ self._attr_unique_id = f"qs{self.qsid}:{self.channel}"
@callback
def update_packet(self, packet):
@@ -62,20 +66,5 @@ class QSBinarySensor(QSEntity, BinarySensorEntity):
packet,
)
if val is not None:
- self._val = bool(val)
+ self._attr_is_on = bool(val) == self._invert
self.async_write_ha_state()
-
- @property
- def is_on(self):
- """Check if device is on (non-zero)."""
- return self._val == self._invert
-
- @property
- def unique_id(self):
- """Return a unique identifier for this sensor."""
- return f"qs{self.qsid}:{self.channel}"
-
- @property
- def device_class(self):
- """Return the class of this sensor."""
- return self._class
diff --git a/homeassistant/components/qwikswitch/const.py b/homeassistant/components/qwikswitch/const.py
new file mode 100644
index 00000000000..2a5cc69af50
--- /dev/null
+++ b/homeassistant/components/qwikswitch/const.py
@@ -0,0 +1,13 @@
+"""Support for Qwikswitch devices."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from pyqwikswitch.async_ import QSUsb
+
+DOMAIN = "qwikswitch"
+DATA_QUIKSWITCH: HassKey[QSUsb] = HassKey(DOMAIN)
diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py
index ff7a1d2e98a..e163b4708a7 100644
--- a/homeassistant/components/qwikswitch/entity.py
+++ b/homeassistant/components/qwikswitch/entity.py
@@ -7,7 +7,7 @@ from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from . import DOMAIN
+from .const import DATA_QUIKSWITCH
class QSEntity(Entity):
@@ -15,21 +15,12 @@ class QSEntity(Entity):
_attr_should_poll = False
- def __init__(self, qsid, name):
+ def __init__(self, qsid: str, name: str) -> None:
"""Initialize the QSEntity."""
- self._name = name
+ self._attr_name = name
+ self._attr_unique_id = f"qs{qsid}"
self.qsid = qsid
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def unique_id(self):
- """Return a unique identifier for this sensor."""
- return f"qs{self.qsid}"
-
@callback
def update_packet(self, packet):
"""Receive update packet from QSUSB. Match dispather_send signature."""
@@ -67,8 +58,8 @@ class QSToggleEntity(QSEntity):
async def async_turn_on(self, **kwargs):
"""Turn the device on."""
new = kwargs.get(ATTR_BRIGHTNESS, 255)
- self.hass.data[DOMAIN].devices.set_value(self.qsid, new)
+ self.hass.data[DATA_QUIKSWITCH].devices.set_value(self.qsid, new)
async def async_turn_off(self, **_):
"""Turn the device off."""
- self.hass.data[DOMAIN].devices.set_value(self.qsid, 0)
+ self.hass.data[DATA_QUIKSWITCH].devices.set_value(self.qsid, 0)
diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py
index e87fae83464..8a3a4f01032 100644
--- a/homeassistant/components/qwikswitch/sensor.py
+++ b/homeassistant/components/qwikswitch/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN
+from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSEntity
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ async def async_setup_platform(
if discovery_info is None:
return
- qsusb = hass.data[DOMAIN]
+ qsusb = hass.data[DATA_QUIKSWITCH]
_LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info)
devs = [QSSensor(sensor) for sensor in discovery_info[DOMAIN]]
add_entities(devs)
@@ -37,21 +37,24 @@ async def async_setup_platform(
class QSSensor(QSEntity, SensorEntity):
"""Sensor based on a Qwikswitch relay/dimmer module."""
- _val: Any | None = None
-
- def __init__(self, sensor):
+ def __init__(self, sensor: dict[str, Any]) -> None:
"""Initialize the sensor."""
super().__init__(sensor["id"], sensor["name"])
self.channel = sensor["channel"]
sensor_type = sensor["type"]
- self._decode, self.unit = SENSORS[sensor_type]
+ self._attr_unique_id = f"qs{self.qsid}:{self.channel}"
+
+ decode, unit = SENSORS[sensor_type]
# this cannot happen because it only happens in bool and this should be redirected to binary_sensor
- assert not isinstance(self.unit, type), (
+ assert not isinstance(unit, type), (
f"boolean sensor id={sensor['id']} name={sensor['name']}"
)
+ self._decode = decode
+ self._attr_native_unit_of_measurement = unit
+
@callback
def update_packet(self, packet):
"""Receive update packet from QSUSB."""
@@ -65,20 +68,5 @@ class QSSensor(QSEntity, SensorEntity):
packet,
)
if val is not None:
- self._val = val
+ self._attr_native_value = str(val)
self.async_write_ha_state()
-
- @property
- def native_value(self):
- """Return the value of the sensor."""
- return None if self._val is None else str(self._val)
-
- @property
- def unique_id(self):
- """Return a unique identifier for this sensor."""
- return f"qs{self.qsid}:{self.channel}"
-
- @property
- def native_unit_of_measurement(self):
- """Return the unit the value is expressed in."""
- return self.unit
diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py
index 6131d9e595c..4b3cddee0d9 100644
--- a/homeassistant/components/qwikswitch/switch.py
+++ b/homeassistant/components/qwikswitch/switch.py
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN
+from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSToggleEntity
@@ -21,7 +21,7 @@ async def async_setup_platform(
if discovery_info is None:
return
- qsusb = hass.data[DOMAIN]
+ qsusb = hass.data[DATA_QUIKSWITCH]
devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[DOMAIN]]
add_entities(devs)
diff --git a/homeassistant/components/rabbitair/manifest.json b/homeassistant/components/rabbitair/manifest.json
index 8f4df8afb7b..f8d80dc5ffc 100644
--- a/homeassistant/components/rabbitair/manifest.json
+++ b/homeassistant/components/rabbitair/manifest.json
@@ -5,6 +5,7 @@
"codeowners": ["@rabbit-air"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rabbitair",
+ "integration_type": "device",
"iot_class": "local_polling",
"requirements": ["python-rabbitair==0.0.8"],
"zeroconf": ["_rabbitair._udp.local."]
diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json
index 185a034d7f2..916a2b7261e 100644
--- a/homeassistant/components/radiotherm/manifest.json
+++ b/homeassistant/components/radiotherm/manifest.json
@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/radiotherm",
+ "integration_type": "device",
"iot_class": "local_polling",
"loggers": ["radiotherm"],
"requirements": ["radiotherm==2.1.0"]
diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json
index 2364b7b014f..93b4f21d7cb 100644
--- a/homeassistant/components/rainbird/manifest.json
+++ b/homeassistant/components/rainbird/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@konikvranik", "@allenporter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainbird",
+ "integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.0.1"]
diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json
index c21241f1cc9..dc22586833c 100644
--- a/homeassistant/components/rainforest_raven/manifest.json
+++ b/homeassistant/components/rainforest_raven/manifest.json
@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/rainforest_raven",
+ "integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["aioraven==0.7.1"],
"usb": [
diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json
index 2684240708f..79a6269f819 100644
--- a/homeassistant/components/rapt_ble/manifest.json
+++ b/homeassistant/components/rapt_ble/manifest.json
@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/rapt_ble",
+ "integration_type": "device",
"iot_class": "local_push",
"requirements": ["rapt-ble==0.1.2"]
}
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index 1b3841fc107..73964eb7e0e 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -59,11 +59,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
+ NitrogenDioxideConcentrationConverter,
+ OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
+ SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -224,6 +227,9 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
CarbonMonoxideConcentrationConverter,
+ NitrogenDioxideConcentrationConverter,
+ OzoneConcentrationConverter,
+ SulphurDioxideConcentrationConverter,
TemperatureDeltaConverter,
]
diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py
index 09d820485dc..0689caaf5bc 100644
--- a/homeassistant/components/recorder/websocket_api.py
+++ b/homeassistant/components/recorder/websocket_api.py
@@ -33,11 +33,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
+ NitrogenDioxideConcentrationConverter,
+ OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
+ SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -84,21 +87,28 @@ UNIT_SCHEMA = vol.Schema(
vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS),
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
- vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS),
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
+ vol.Optional("nitrogen_dioxide"): vol.In(
+ NitrogenDioxideConcentrationConverter.VALID_UNITS
+ ),
+ vol.Optional("ozone"): vol.In(OzoneConcentrationConverter.VALID_UNITS),
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS),
vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS),
vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS),
+ vol.Optional("sulphur_dioxide"): vol.In(
+ SulphurDioxideConcentrationConverter.VALID_UNITS
+ ),
vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional("temperature_delta"): vol.In(
TemperatureDeltaConverter.VALID_UNITS
),
vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS),
+ vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS),
vol.Optional("volume_flow_rate"): vol.In(VolumeFlowRateConverter.VALID_UNITS),
}
diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json
index 93ffe5b3f26..5227d783f1f 100644
--- a/homeassistant/components/refoss/manifest.json
+++ b/homeassistant/components/refoss/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@ashionky"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/refoss",
+ "integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["refoss-ha==1.2.5"],
"single_config_entry": true
diff --git a/homeassistant/components/rehlko/manifest.json b/homeassistant/components/rehlko/manifest.json
index d73f8c42584..ab4805472ff 100644
--- a/homeassistant/components/rehlko/manifest.json
+++ b/homeassistant/components/rehlko/manifest.json
@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/rehlko",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiokem"],
"quality_scale": "silver",
diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json
index e5f9b54cd7a..1685c1b1cae 100644
--- a/homeassistant/components/renault/strings.json
+++ b/homeassistant/components/renault/strings.json
@@ -12,6 +12,9 @@
"invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"kamereon": {
"data": {
diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json
index fcc482959f2..2c1e24244ae 100644
--- a/homeassistant/components/renson/manifest.json
+++ b/homeassistant/components/renson/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@jimmyd-be"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/renson",
+ "integration_type": "device",
"iot_class": "local_polling",
"requirements": ["renson-endura-delta==1.7.2"]
}
diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json
index bb3701e2e31..34df4c26c18 100644
--- a/homeassistant/components/rfxtrx/manifest.json
+++ b/homeassistant/components/rfxtrx/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
+ "integration_type": "hub",
"iot_class": "local_push",
"loggers": ["RFXtrx"],
"requirements": ["pyRFXtrx==0.31.1"]
diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json
index 0acee2ae604..39d5084c1a3 100644
--- a/homeassistant/components/rituals_perfume_genie/manifest.json
+++ b/homeassistant/components/rituals_perfume_genie/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
"requirements": ["pyrituals==0.0.7"]
diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py
index 20ae0708c15..96eda4b5609 100644
--- a/homeassistant/components/rocketchat/notify.py
+++ b/homeassistant/components/rocketchat/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
+from typing import Any
from rocketchat_API.APIExceptions.RocketExceptions import (
RocketAuthenticationException,
@@ -69,7 +70,7 @@ class RocketChatNotificationService(BaseNotificationService):
self._room = room
self._server = RocketChat(username, password, server_url=url)
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to Rocket.Chat."""
data = kwargs.get(ATTR_DATA) or {}
resp = self._server.chat_post_message(message, channel=self._room, **data)
diff --git a/homeassistant/components/romy/manifest.json b/homeassistant/components/romy/manifest.json
index efb8072ebbc..1169bee99ff 100644
--- a/homeassistant/components/romy/manifest.json
+++ b/homeassistant/components/romy/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@xeniter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/romy",
+ "integration_type": "device",
"iot_class": "local_polling",
"requirements": ["romy==0.0.10"],
"zeroconf": ["_aicu-http._tcp.local."]
diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json
index 24af14bfba3..1ded2f6a9ce 100644
--- a/homeassistant/components/roomba/manifest.json
+++ b/homeassistant/components/roomba/manifest.json
@@ -22,6 +22,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/roomba",
+ "integration_type": "device",
"iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"],
"requirements": ["roombapy==1.9.0"],
diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json
index 0dcb5b87581..f583f89e3eb 100644
--- a/homeassistant/components/roon/manifest.json
+++ b/homeassistant/components/roon/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@pavoni"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roon",
+ "integration_type": "hub",
"iot_class": "local_push",
"loggers": ["roonapi"],
"requirements": ["roonapi==0.1.6"]
diff --git a/homeassistant/components/rova/manifest.json b/homeassistant/components/rova/manifest.json
index b867cac8e7a..b541d60bd2d 100644
--- a/homeassistant/components/rova/manifest.json
+++ b/homeassistant/components/rova/manifest.json
@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rova",
+ "integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["rova"],
"requirements": ["rova==0.4.1"]
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index 5973d1ccea6..43683722a0c 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@noahhusby"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
+ "integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
diff --git a/homeassistant/components/ruuvi_gateway/manifest.json b/homeassistant/components/ruuvi_gateway/manifest.json
index a9284893973..92fdfd823cd 100644
--- a/homeassistant/components/ruuvi_gateway/manifest.json
+++ b/homeassistant/components/ruuvi_gateway/manifest.json
@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway",
+ "integration_type": "device",
"iot_class": "local_polling",
"requirements": ["aioruuvigateway==0.1.0"]
}
diff --git a/homeassistant/components/ruuvitag_ble/manifest.json b/homeassistant/components/ruuvitag_ble/manifest.json
index 586e227cc78..bf70dbaa413 100644
--- a/homeassistant/components/ruuvitag_ble/manifest.json
+++ b/homeassistant/components/ruuvitag_ble/manifest.json
@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
+ "integration_type": "device",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.4.0"]
}
diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json
index 51c26b312fb..a6c8705afdd 100644
--- a/homeassistant/components/rympro/manifest.json
+++ b/homeassistant/components/rympro/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@OnFreund", "@elad-bar", "@maorcc"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rympro",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyrympro==0.0.9"]
}
diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json
index f1b8a17134b..2b17f3484d0 100644
--- a/homeassistant/components/sabnzbd/manifest.json
+++ b/homeassistant/components/sabnzbd/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@shaiu", "@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
+ "integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pysabnzbd"],
"quality_scale": "bronze",
diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py
index e9bea5f7c06..e2988b4b713 100644
--- a/homeassistant/components/saunum/__init__.py
+++ b/homeassistant/components/saunum/__init__.py
@@ -26,11 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]
- client = SaunumClient(host=host)
-
- # Test connection
try:
- await client.connect()
+ client = await SaunumClient.create(host)
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
@@ -47,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- coordinator = entry.runtime_data
- coordinator.client.close()
+ await entry.runtime_data.client.async_close()
return unload_ok
diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py
index f90703edea2..887559800e8 100644
--- a/homeassistant/components/saunum/climate.py
+++ b/homeassistant/components/saunum/climate.py
@@ -17,13 +17,22 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import LeilSaunaConfigEntry
-from .const import DELAYED_REFRESH_SECONDS, DOMAIN
+from . import LeilSaunaConfigEntry, LeilSaunaCoordinator
+from .const import (
+ DEFAULT_PRESET_NAME_TYPE_1,
+ DEFAULT_PRESET_NAME_TYPE_2,
+ DEFAULT_PRESET_NAME_TYPE_3,
+ DELAYED_REFRESH_SECONDS,
+ DOMAIN,
+ OPT_PRESET_NAME_TYPE_1,
+ OPT_PRESET_NAME_TYPE_2,
+ OPT_PRESET_NAME_TYPE_3,
+)
from .entity import LeilSaunaEntity
PARALLEL_UPDATES = 1
@@ -52,14 +61,51 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Representation of a Saunum Leil Sauna climate entity."""
_attr_name = None
+ _attr_translation_key = "saunum_climate"
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = (
- ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.FAN_MODE
+ | ClimateEntityFeature.PRESET_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_precision = PRECISION_WHOLE
+ _attr_target_temperature_step = 1.0
_attr_min_temp = MIN_TEMPERATURE
_attr_max_temp = MAX_TEMPERATURE
_attr_fan_modes = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
+ _preset_name_map: dict[int, str]
+
+ def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
+ """Initialize the climate entity."""
+ super().__init__(coordinator)
+ self._update_preset_names()
+
+ def _update_preset_names(self) -> None:
+ """Update preset names from config entry options."""
+ options = self.coordinator.config_entry.options
+ self._preset_name_map = {
+ 0: options.get(OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1),
+ 1: options.get(OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2),
+ 2: options.get(OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3),
+ }
+ self._attr_preset_modes = list(self._preset_name_map.values())
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.coordinator.config_entry.add_update_listener(
+ self._async_update_listener
+ )
+ )
+
+ async def _async_update_listener(
+ self, hass: HomeAssistant, entry: LeilSaunaConfigEntry
+ ) -> None:
+ """Handle options update."""
+ self._update_preset_names()
+ self.async_write_ha_state()
@property
def current_temperature(self) -> float | None:
@@ -98,6 +144,14 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
else HVACAction.IDLE
)
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the current preset mode."""
+ sauna_type = self.coordinator.data.sauna_type
+ if sauna_type is not None and sauna_type in self._preset_name_map:
+ return self._preset_name_map[sauna_type]
+ return self._preset_name_map[0]
+
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
if hvac_mode == HVACMode.HEAT and self.coordinator.data.door_open:
@@ -143,10 +197,44 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Set new fan mode."""
if not self.coordinator.data.session_active:
raise ServiceValidationError(
- "Cannot change fan mode when sauna session is not active",
translation_domain=DOMAIN,
translation_key="session_not_active",
)
- await self.coordinator.client.async_set_fan_speed(FAN_MODE_TO_SPEED[fan_mode])
+ try:
+ await self.coordinator.client.async_set_fan_speed(
+ FAN_MODE_TO_SPEED[fan_mode]
+ )
+ except SaunumException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_fan_mode_failed",
+ ) from err
+
+ await self.coordinator.async_request_refresh()
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new preset mode (sauna type)."""
+ if self.coordinator.data.session_active:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="preset_session_active",
+ )
+
+ # Find the sauna type value from the preset name
+ sauna_type_value = 0 # Default to type 1
+ for type_value, type_name in self._preset_name_map.items():
+ if type_name == preset_mode:
+ sauna_type_value = type_value
+ break
+
+ try:
+ await self.coordinator.client.async_set_sauna_type(sauna_type_value)
+ except SaunumException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_preset_failed",
+ translation_placeholders={"preset_mode": preset_mode},
+ ) from err
+
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/saunum/config_flow.py b/homeassistant/components/saunum/config_flow.py
index 20ce9f859a7..a13525537bf 100644
--- a/homeassistant/components/saunum/config_flow.py
+++ b/homeassistant/components/saunum/config_flow.py
@@ -8,11 +8,26 @@ from typing import Any
from pysaunum import SaunumClient, SaunumException
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_USER,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_HOST
+from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from .const import DOMAIN
+from . import LeilSaunaConfigEntry
+from .const import (
+ DEFAULT_PRESET_NAME_TYPE_1,
+ DEFAULT_PRESET_NAME_TYPE_2,
+ DEFAULT_PRESET_NAME_TYPE_3,
+ DOMAIN,
+ OPT_PRESET_NAME_TYPE_1,
+ OPT_PRESET_NAME_TYPE_2,
+ OPT_PRESET_NAME_TYPE_3,
+)
_LOGGER = logging.getLogger(__name__)
@@ -30,14 +45,13 @@ async def validate_input(data: dict[str, Any]) -> None:
"""
host = data[CONF_HOST]
- client = SaunumClient(host=host)
+ client = await SaunumClient.create(host)
try:
- await client.connect()
# Try to read data to verify communication
await client.async_get_data()
finally:
- client.close()
+ await client.async_close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -46,6 +60,14 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: LeilSaunaConfigEntry,
+ ) -> LeilSaunaOptionsFlow:
+ """Get the options flow for this handler."""
+ return LeilSaunaOptionsFlow()
+
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -83,3 +105,40 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
+
+
+class LeilSaunaOptionsFlow(OptionsFlow):
+ """Handle options flow for Saunum Leil Sauna Control Unit."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options for preset mode names."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ OPT_PRESET_NAME_TYPE_1,
+ default=self.config_entry.options.get(
+ OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1
+ ),
+ ): cv.string,
+ vol.Optional(
+ OPT_PRESET_NAME_TYPE_2,
+ default=self.config_entry.options.get(
+ OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2
+ ),
+ ): cv.string,
+ vol.Optional(
+ OPT_PRESET_NAME_TYPE_3,
+ default=self.config_entry.options.get(
+ OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3
+ ),
+ ): cv.string,
+ }
+ ),
+ )
diff --git a/homeassistant/components/saunum/const.py b/homeassistant/components/saunum/const.py
index 0c841313ad2..beb5589c79c 100644
--- a/homeassistant/components/saunum/const.py
+++ b/homeassistant/components/saunum/const.py
@@ -7,3 +7,13 @@ DOMAIN: Final = "saunum"
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)
+
+# Option keys for preset names
+OPT_PRESET_NAME_TYPE_1: Final = "preset_name_type_1"
+OPT_PRESET_NAME_TYPE_2: Final = "preset_name_type_2"
+OPT_PRESET_NAME_TYPE_3: Final = "preset_name_type_3"
+
+# Default preset names (translation keys)
+DEFAULT_PRESET_NAME_TYPE_1: Final = "type_1"
+DEFAULT_PRESET_NAME_TYPE_2: Final = "type_2"
+DEFAULT_PRESET_NAME_TYPE_3: Final = "type_3"
diff --git a/homeassistant/components/saunum/diagnostics.py b/homeassistant/components/saunum/diagnostics.py
index 2f348dfa50c..5e42e926d33 100644
--- a/homeassistant/components/saunum/diagnostics.py
+++ b/homeassistant/components/saunum/diagnostics.py
@@ -23,6 +23,7 @@ async def async_get_config_entry_diagnostics(
# Build diagnostics data
diagnostics_data: dict[str, Any] = {
"config": async_redact_data(entry.data, REDACT_CONFIG),
+ "options": dict(entry.options),
"client_info": {"connected": coordinator.client.is_connected},
"coordinator_info": {
"last_update_success": coordinator.last_update_success,
diff --git a/homeassistant/components/saunum/icons.json b/homeassistant/components/saunum/icons.json
index 186f86a6d86..713983b8114 100644
--- a/homeassistant/components/saunum/icons.json
+++ b/homeassistant/components/saunum/icons.json
@@ -1,5 +1,19 @@
{
"entity": {
+ "climate": {
+ "saunum_climate": {
+ "state_attributes": {
+ "preset_mode": {
+ "default": "mdi:heat-wave",
+ "state": {
+ "type_1": "mdi:numeric-1-box-outline",
+ "type_2": "mdi:numeric-2-box-outline",
+ "type_3": "mdi:numeric-3-box-outline"
+ }
+ }
+ }
+ }
+ },
"number": {
"fan_duration": {
"default": "mdi:fan-clock"
diff --git a/homeassistant/components/saunum/light.py b/homeassistant/components/saunum/light.py
index 179672b4737..30be9924f08 100644
--- a/homeassistant/components/saunum/light.py
+++ b/homeassistant/components/saunum/light.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
from pysaunum import SaunumException
@@ -15,6 +15,9 @@ from . import LeilSaunaConfigEntry
from .const import DOMAIN
from .entity import LeilSaunaEntity
+if TYPE_CHECKING:
+ from .coordinator import LeilSaunaCoordinator
+
PARALLEL_UPDATES = 1
@@ -35,7 +38,7 @@ class LeilSaunaLight(LeilSaunaEntity, LightEntity):
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
- def __init__(self, coordinator) -> None:
+ def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
"""Initialize the light entity."""
super().__init__(coordinator)
# Override unique_id to differentiate from climate entity
diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json
index 50ba04dbf6a..65ed36fa79d 100644
--- a/homeassistant/components/saunum/manifest.json
+++ b/homeassistant/components/saunum/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
- "quality_scale": "gold",
- "requirements": ["pysaunum==0.1.0"]
+ "quality_scale": "platinum",
+ "requirements": ["pysaunum==0.3.0"]
}
diff --git a/homeassistant/components/saunum/number.py b/homeassistant/components/saunum/number.py
index cd12df201cc..0a59127ffd6 100644
--- a/homeassistant/components/saunum/number.py
+++ b/homeassistant/components/saunum/number.py
@@ -133,11 +133,7 @@ class LeilSaunaNumber(LeilSaunaEntity, NumberEntity):
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="set_value_failed",
- translation_placeholders={
- "entity": self.entity_description.key,
- "value": str(value),
- },
+ translation_key=f"set_{self.entity_description.key}_failed",
) from err
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml
index f681f142467..4a7d29777b4 100644
--- a/homeassistant/components/saunum/quality_scale.yaml
+++ b/homeassistant/components/saunum/quality_scale.yaml
@@ -73,8 +73,8 @@ rules:
comment: Integration controls a single device; no dynamic device discovery needed.
# Platinum
- async-dependency: todo
+ async-dependency: done
inject-websession:
status: exempt
comment: Integration uses Modbus TCP protocol and does not make HTTP requests.
- strict-typing: todo
+ strict-typing: done
diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json
index e72fad37fa6..945cff52c08 100644
--- a/homeassistant/components/saunum/strings.json
+++ b/homeassistant/components/saunum/strings.json
@@ -50,6 +50,19 @@
"name": "Thermal cutoff alarm"
}
},
+ "climate": {
+ "saunum_climate": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "type_1": "Sauna Type 1",
+ "type_2": "Sauna Type 2",
+ "type_3": "Sauna Type 3"
+ }
+ }
+ }
+ }
+ },
"light": {
"light": {
"name": "[%key:component::light::title%]"
@@ -80,15 +93,24 @@
"door_open": {
"message": "Cannot start sauna session when sauna door is open"
},
+ "preset_session_active": {
+ "message": "Cannot change preset mode while sauna session is active"
+ },
"session_active_cannot_change_fan_duration": {
- "message": "Cannot change fan duration while session is active"
+ "message": "Cannot change fan duration while sauna session is active"
},
"session_active_cannot_change_sauna_duration": {
- "message": "Cannot change sauna duration while session is active"
+ "message": "Cannot change sauna duration while sauna session is active"
},
"session_not_active": {
"message": "Cannot change fan mode when sauna session is not active"
},
+ "set_fan_duration_failed": {
+ "message": "Failed to set fan duration"
+ },
+ "set_fan_mode_failed": {
+ "message": "Failed to set fan mode"
+ },
"set_hvac_mode_failed": {
"message": "Failed to set HVAC mode to {hvac_mode}"
},
@@ -98,11 +120,31 @@
"set_light_on_failed": {
"message": "Failed to turn on light"
},
+ "set_preset_failed": {
+ "message": "Failed to set preset to {preset_mode}"
+ },
+ "set_sauna_duration_failed": {
+ "message": "Failed to set sauna duration"
+ },
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}"
- },
- "set_value_failed": {
- "message": "Failed to set {entity} to {value}"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "preset_name_type_1": "Preset name for sauna type 1",
+ "preset_name_type_2": "Preset name for sauna type 2",
+ "preset_name_type_3": "Preset name for sauna type 3"
+ },
+ "data_description": {
+ "preset_name_type_1": "Custom name for sauna type 1 preset mode",
+ "preset_name_type_2": "Custom name for sauna type 2 preset mode",
+ "preset_name_type_3": "Custom name for sauna type 3 preset mode"
+ },
+ "description": "Customize the names of the three sauna type preset modes"
+ }
}
}
}
diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json
index eadf5585f30..145978b94bf 100644
--- a/homeassistant/components/schlage/manifest.json
+++ b/homeassistant/components/schlage/manifest.json
@@ -4,6 +4,7 @@
"codeowners": ["@dknowles2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyschlage==2025.9.0"]
}
diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py
index 3e7f416166b..c6682fba5a8 100644
--- a/homeassistant/components/scrape/sensor.py
+++ b/homeassistant/components/scrape/sensor.py
@@ -142,6 +142,8 @@ async def async_setup_entry(
class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity):
"""Representation of a web scrape sensor."""
+ _sensor_name: str | None = None
+
def __init__(
self,
hass: HomeAssistant,
@@ -162,14 +164,26 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti
self._value_template = value_template
self._attr_native_value = None
if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)):
- self._attr_name = None
+ self._sensor_name = None
self._attr_has_entity_name = True
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
manufacturer="Scrape",
- name=self.name,
+ name=self._rendered[CONF_NAME],
)
+ else:
+ self._sensor_name = self._rendered.get(CONF_NAME)
+
+ @property
+ def name(self) -> str | None:
+ """Return the name of the sensor.
+
+ Override needed because TriggerBaseEntity.name always returns the
+ rendered name, ignoring _attr_name. When has_entity_name is True,
+ we need name to return None to use the device name instead.
+ """
+ return self._sensor_name
def _extract_value(self) -> Any:
"""Parse the html extraction in the executor."""
diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py
index 4dbb95085cb..613329c3658 100644
--- a/homeassistant/components/sendgrid/notify.py
+++ b/homeassistant/components/sendgrid/notify.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
import logging
+from typing import Any
from sendgrid import SendGridAPIClient
import voluptuous as vol
@@ -61,7 +62,7 @@ class SendgridNotificationService(BaseNotificationService):
self._sg = SendGridAPIClient(self.api_key)
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send an email to a user via SendGrid."""
subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index 33106f0fd1b..351d3bea7c2 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -18,6 +18,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/sense",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.13.8"]
diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json
index 4cadd3f8692..d58bc1a00fe 100644
--- a/homeassistant/components/sensibo/manifest.json
+++ b/homeassistant/components/sensibo/manifest.json
@@ -12,6 +12,7 @@
"homekit": {
"models": ["Sensibo"]
},
+ "integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
"quality_scale": "platinum",
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index b9ded0dbe7b..be6cc4ea3a5 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -63,11 +63,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
+ NitrogenDioxideConcentrationConverter,
+ OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
+ SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -158,7 +161,7 @@ class SensorDeviceClass(StrEnum):
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
- Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³`
"""
CO2 = "carbon_dioxide"
@@ -282,7 +285,7 @@ class SensorDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -300,7 +303,7 @@ class SensorDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion),`μg/m³`
"""
PH = "ph"
@@ -409,7 +412,7 @@ class SensorDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
- Unit of measurement: `μg/m³`
+ Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -562,6 +565,8 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
SensorDeviceClass.ENERGY_STORAGE: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
+ SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
+ SensorDeviceClass.OZONE: OzoneConcentrationConverter,
SensorDeviceClass.POWER: PowerConverter,
SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
SensorDeviceClass.PRECIPITATION: DistanceConverter,
@@ -569,6 +574,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter,
SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter,
+ SensorDeviceClass.SULPHUR_DIOXIDE: SulphurDioxideConcentrationConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
SensorDeviceClass.TEMPERATURE_DELTA: TemperatureDeltaConverter,
@@ -595,6 +601,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.BATTERY: {PERCENTAGE},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
SensorDeviceClass.CO: {
+ CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -628,10 +635,16 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
SensorDeviceClass.MOISTURE: {PERCENTAGE},
- SensorDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ SensorDeviceClass.NITROGEN_DIOXIDE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
SensorDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
- SensorDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ SensorDeviceClass.OZONE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
SensorDeviceClass.PH: {None},
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
@@ -657,7 +670,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
},
SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
- SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ SensorDeviceClass.SULPHUR_DIOXIDE: {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ },
SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature),
SensorDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {
diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py
index b85baa1a630..dcaacde9ba9 100644
--- a/homeassistant/components/shelly/binary_sensor.py
+++ b/homeassistant/components/shelly/binary_sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Final, cast
-from aioshelly.const import RPC_GENERATIONS
+from aioshelly.const import MODEL_FLOOD_G4, RPC_GENERATIONS
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_PLATFORM,
@@ -335,6 +335,7 @@ RPC_SENSORS: Final = {
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
supported=lambda status: status.get("alarm") is not None,
+ models={MODEL_FLOOD_G4},
),
"presence_num_objects": RpcBinarySensorDescription(
key="presence",
diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py
index 8c906d26c23..47f8d6b5a87 100644
--- a/homeassistant/components/sinch/notify.py
+++ b/homeassistant/components/sinch/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
from clx.xms.api import MtBatchTextSmsResult
from clx.xms.client import Client
@@ -67,7 +68,7 @@ class SinchNotificationService(BaseNotificationService):
self.sender = config[CONF_SENDER]
self.client = Client(config[CONF_SERVICE_PLAN_ID], config[CONF_API_KEY])
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
targets = kwargs.get(ATTR_TARGET, self.default_recipients)
data = kwargs.get(ATTR_DATA) or {}
diff --git a/homeassistant/components/siren/condition.py b/homeassistant/components/siren/condition.py
new file mode 100644
index 00000000000..2593b00428d
--- /dev/null
+++ b/homeassistant/components/siren/condition.py
@@ -0,0 +1,17 @@
+"""Provides conditions for sirens."""
+
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.condition import Condition, make_entity_state_condition
+
+from . import DOMAIN
+
+CONDITIONS: dict[str, type[Condition]] = {
+ "is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
+ "is_on": make_entity_state_condition(DOMAIN, STATE_ON),
+}
+
+
+async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
+ """Return the siren conditions."""
+ return CONDITIONS
diff --git a/homeassistant/components/siren/conditions.yaml b/homeassistant/components/siren/conditions.yaml
new file mode 100644
index 00000000000..41145760d92
--- /dev/null
+++ b/homeassistant/components/siren/conditions.yaml
@@ -0,0 +1,17 @@
+.condition_common: &condition_common
+ target:
+ entity:
+ domain: siren
+ fields:
+ behavior:
+ required: true
+ default: any
+ selector:
+ select:
+ translation_key: condition_behavior
+ options:
+ - all
+ - any
+
+is_off: *condition_common
+is_on: *condition_common
diff --git a/homeassistant/components/siren/icons.json b/homeassistant/components/siren/icons.json
index e43289ffcf0..fa13d285767 100644
--- a/homeassistant/components/siren/icons.json
+++ b/homeassistant/components/siren/icons.json
@@ -1,4 +1,12 @@
{
+ "conditions": {
+ "is_off": {
+ "condition": "mdi:bullhorn-outline"
+ },
+ "is_on": {
+ "condition": "mdi:bullhorn"
+ }
+ },
"entity_component": {
"_": {
"default": "mdi:bullhorn"
diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json
index ff78a9a48d8..4c4b186d58d 100644
--- a/homeassistant/components/siren/strings.json
+++ b/homeassistant/components/siren/strings.json
@@ -1,8 +1,32 @@
{
"common": {
+ "condition_behavior_description": "How the state should match on the targeted sirens.",
+ "condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
"trigger_behavior_name": "Behavior"
},
+ "conditions": {
+ "is_off": {
+ "description": "Tests if one or more sirens are off.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::siren::common::condition_behavior_description%]",
+ "name": "[%key:component::siren::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a siren is off"
+ },
+ "is_on": {
+ "description": "Tests if one or more sirens are on.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::siren::common::condition_behavior_description%]",
+ "name": "[%key:component::siren::common::condition_behavior_name%]"
+ }
+ },
+ "name": "If a siren is on"
+ }
+ },
"entity_component": {
"_": {
"name": "[%key:component::siren::title%]",
@@ -18,6 +42,12 @@
}
},
"selector": {
+ "condition_behavior": {
+ "options": {
+ "all": "All",
+ "any": "Any"
+ }
+ },
"trigger_behavior": {
"options": {
"any": "Any",
diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py
index 7d76fbe0ec9..9e9656eb4b9 100644
--- a/homeassistant/components/sma/config_flow.py
+++ b/homeassistant/components/sma/config_flow.py
@@ -144,6 +144,51 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ reconf_entry = self._get_reconfigure_entry()
+ if user_input is not None:
+ errors, device_info = await self._handle_user_input(
+ user_input={
+ **reconf_entry.data,
+ **user_input,
+ }
+ )
+
+ if not errors:
+ await self.async_set_unique_id(
+ str(device_info["serial"]), raise_on_progress=False
+ )
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ reconf_entry,
+ data_updates={
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_SSL: user_input[CONF_SSL],
+ CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
+ CONF_GROUP: user_input[CONF_GROUP],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_SSL): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_GROUP): vol.In(GROUPS),
+ }
+ ),
+ suggested_values=user_input or dict(reconf_entry.data),
+ ),
+ errors=errors,
+ )
+
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json
index 07e4047de54..55f2d2512b7 100644
--- a/homeassistant/components/sma/strings.json
+++ b/homeassistant/components/sma/strings.json
@@ -3,7 +3,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unique_id_mismatch": "You selected a different SMA device than the one this config entry was configured with, this is not allowed."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -29,6 +31,16 @@
"description": "The SMA integration needs to re-authenticate your connection details",
"title": "[%key:common::config_flow::title::reauth%]"
},
+ "reconfigure": {
+ "data": {
+ "group": "[%key:component::sma::config::step::user::data::group%]",
+ "host": "[%key:common::config_flow::data::host%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "description": "Use the following form to reconfigure your SMA device.",
+ "title": "Reconfigure SMA Solar Integration"
+ },
"user": {
"data": {
"group": "Group",
@@ -44,5 +56,13 @@
"title": "Set up SMA Solar"
}
}
+ },
+ "selector": {
+ "group": {
+ "options": {
+ "installer": "Installer",
+ "user": "User"
+ }
+ }
}
}
diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json
index ed25822ee7f..c04bc67e976 100644
--- a/homeassistant/components/smartthings/strings.json
+++ b/homeassistant/components/smartthings/strings.json
@@ -18,6 +18,9 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"pick_implementation": {
"data": {
diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json
index 9a2f9069d76..2f88028e28e 100644
--- a/homeassistant/components/spotify/strings.json
+++ b/homeassistant/components/spotify/strings.json
@@ -16,6 +16,9 @@
"create_entry": {
"default": "Successfully authenticated with Spotify."
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"pick_implementation": {
"data": {
diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py
index 2137480cea8..893b596e11d 100644
--- a/homeassistant/components/sunricher_dali/__init__.py
+++ b/homeassistant/components/sunricher_dali/__init__.py
@@ -25,7 +25,12 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
from .types import DaliCenterConfigEntry, DaliCenterData
-_PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
+_PLATFORMS: list[Platform] = [
+ Platform.BUTTON,
+ Platform.LIGHT,
+ Platform.SCENE,
+ Platform.SENSOR,
+]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sunricher_dali/button.py b/homeassistant/components/sunricher_dali/button.py
new file mode 100644
index 00000000000..9ba034924bf
--- /dev/null
+++ b/homeassistant/components/sunricher_dali/button.py
@@ -0,0 +1,63 @@
+"""Support for Sunricher DALI device identify button."""
+
+from __future__ import annotations
+
+import logging
+
+from PySrDaliGateway import Device
+from PySrDaliGateway.helper import is_light_device
+
+from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN, MANUFACTURER
+from .entity import DaliDeviceEntity
+from .types import DaliCenterConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: DaliCenterConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Sunricher DALI button entities from config entry."""
+ devices = entry.runtime_data.devices
+
+ async_add_entities(
+ DaliCenterIdentifyButton(device)
+ for device in devices
+ if is_light_device(device.dev_type)
+ )
+
+
+class DaliCenterIdentifyButton(DaliDeviceEntity, ButtonEntity):
+ """Representation of a Sunricher DALI device identify button."""
+
+ _attr_device_class = ButtonDeviceClass.IDENTIFY
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_name = None
+
+ def __init__(self, device: Device) -> None:
+ """Initialize the device identify button."""
+ super().__init__(device)
+ self._device = device
+ self._attr_unique_id = f"{device.unique_id}_identify"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.dev_id)},
+ name=device.name,
+ manufacturer=MANUFACTURER,
+ model=device.model,
+ via_device=(DOMAIN, device.gw_sn),
+ )
+
+ async def async_press(self) -> None:
+ """Handle button press to identify device."""
+ _LOGGER.debug("Identifying device %s", self._device.dev_id)
+ self._device.identify()
diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json
index 214c822fc01..80524a9bfb1 100644
--- a/homeassistant/components/sunricher_dali/manifest.json
+++ b/homeassistant/components/sunricher_dali/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"iot_class": "local_push",
"quality_scale": "silver",
- "requirements": ["PySrDaliGateway==0.18.0"]
+ "requirements": ["PySrDaliGateway==0.19.3"]
}
diff --git a/homeassistant/components/sunricher_dali/sensor.py b/homeassistant/components/sunricher_dali/sensor.py
new file mode 100644
index 00000000000..19dc58d5168
--- /dev/null
+++ b/homeassistant/components/sunricher_dali/sensor.py
@@ -0,0 +1,121 @@
+"""Platform for Sunricher DALI sensor entities."""
+
+from __future__ import annotations
+
+import logging
+
+from PySrDaliGateway import CallbackEventType, Device
+from PySrDaliGateway.helper import is_illuminance_sensor
+from PySrDaliGateway.types import IlluminanceStatus
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
+from homeassistant.const import LIGHT_LUX
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN, MANUFACTURER
+from .entity import DaliDeviceEntity
+from .types import DaliCenterConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: DaliCenterConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Sunricher DALI sensor entities from config entry."""
+ devices = entry.runtime_data.devices
+
+ entities: list[SensorEntity] = [
+ SunricherDaliIlluminanceSensor(device)
+ for device in devices
+ if is_illuminance_sensor(device.dev_type)
+ ]
+
+ if entities:
+ async_add_entities(entities)
+
+
+class SunricherDaliIlluminanceSensor(DaliDeviceEntity, SensorEntity):
+ """Representation of a Sunricher DALI Illuminance Sensor."""
+
+ _attr_device_class = SensorDeviceClass.ILLUMINANCE
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_native_unit_of_measurement = LIGHT_LUX
+ _attr_name = None
+
+ def __init__(self, device: Device) -> None:
+ """Initialize the illuminance sensor."""
+ super().__init__(device)
+ self._device = device
+ self._illuminance_value: float | None = None
+ self._sensor_enabled: bool = True
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.dev_id)},
+ name=device.name,
+ manufacturer=MANUFACTURER,
+ model=device.model,
+ via_device=(DOMAIN, device.gw_sn),
+ )
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the native value, or None if sensor is disabled."""
+ if not self._sensor_enabled:
+ return None
+ return self._illuminance_value
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity addition to Home Assistant."""
+ await super().async_added_to_hass()
+
+ self.async_on_remove(
+ self._device.register_listener(
+ CallbackEventType.ILLUMINANCE_STATUS, self._handle_illuminance_status
+ )
+ )
+
+ self.async_on_remove(
+ self._device.register_listener(
+ CallbackEventType.SENSOR_ON_OFF, self._handle_sensor_on_off
+ )
+ )
+
+ self._device.read_status()
+
+ @callback
+ def _handle_illuminance_status(self, status: IlluminanceStatus) -> None:
+ """Handle illuminance status updates."""
+ illuminance_value = status["illuminance_value"]
+ is_valid = status["is_valid"]
+
+ if not is_valid:
+ _LOGGER.debug(
+ "Illuminance value is not valid for device %s: %s lux",
+ self._device.dev_id,
+ illuminance_value,
+ )
+ return
+
+ self._illuminance_value = illuminance_value
+ self.schedule_update_ha_state()
+
+ @callback
+ def _handle_sensor_on_off(self, on_off: bool) -> None:
+ """Handle sensor on/off updates."""
+ self._sensor_enabled = on_off
+ _LOGGER.debug(
+ "Illuminance sensor enable state for device %s updated to: %s",
+ self._device.dev_id,
+ on_off,
+ )
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py
index dc1bf8e9701..2001bcaf524 100644
--- a/homeassistant/components/switchbot_cloud/__init__.py
+++ b/homeassistant/components/switchbot_cloud/__init__.py
@@ -185,6 +185,9 @@ async def make_device_data(
"Smart Lock Lite",
"Smart Lock Pro",
"Smart Lock Ultra",
+ "Smart Lock Vision",
+ "Smart Lock Vision Pro",
+ "Smart Lock Pro Wifi",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py
index 316badc42f7..83dff65ec72 100644
--- a/homeassistant/components/switchbot_cloud/binary_sensor.py
+++ b/homeassistant/components/switchbot_cloud/binary_sensor.py
@@ -92,6 +92,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
+ "Smart Lock Vision": (
+ CALIBRATION_DESCRIPTION,
+ DOOR_OPEN_DESCRIPTION,
+ ),
+ "Smart Lock Vision Pro": (
+ CALIBRATION_DESCRIPTION,
+ DOOR_OPEN_DESCRIPTION,
+ ),
+ "Smart Lock Pro Wifi": (
+ CALIBRATION_DESCRIPTION,
+ DOOR_OPEN_DESCRIPTION,
+ ),
"Curtain": (CALIBRATION_DESCRIPTION,),
"Curtain3": (CALIBRATION_DESCRIPTION,),
"Roller Shade": (CALIBRATION_DESCRIPTION,),
diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py
index ed852cc7420..191b17c397e 100644
--- a/homeassistant/components/switchbot_cloud/lock.py
+++ b/homeassistant/components/switchbot_cloud/lock.py
@@ -46,7 +46,7 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
"""Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
self._attr_is_locked = coord_data["lockState"] == "locked"
- if self.__model in LockV2Commands.get_supported_devices():
+ if self.__model != "Smart Lock Lite":
self._attr_supported_features = LockEntityFeature.OPEN
async def async_lock(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json
index 8a642aaec77..737ddeeef89 100644
--- a/homeassistant/components/switchbot_cloud/manifest.json
+++ b/homeassistant/components/switchbot_cloud/manifest.json
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["switchbot_api"],
- "requirements": ["switchbot-api==2.9.0"]
+ "requirements": ["switchbot-api==2.10.0"]
}
diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py
index 9f689acc006..b5ff0e88e61 100644
--- a/homeassistant/components/switchbot_cloud/sensor.py
+++ b/homeassistant/components/switchbot_cloud/sensor.py
@@ -225,6 +225,9 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
+ "Smart Lock Vision": (BATTERY_DESCRIPTION,),
+ "Smart Lock Vision Pro": (BATTERY_DESCRIPTION,),
+ "Smart Lock Pro Wifi": (BATTERY_DESCRIPTION,),
"Relay Switch 2PM": (
RELAY_SWITCH_2PM_POWER_DESCRIPTION,
RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION,
diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py
index 37ea3238a06..a4ae3b1aaa2 100644
--- a/homeassistant/components/synology_chat/notify.py
+++ b/homeassistant/components/synology_chat/notify.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
+from typing import Any
import requests
import voluptuous as vol
@@ -51,7 +52,7 @@ class SynologyChatNotificationService(BaseNotificationService):
self._resource = resource
self._verify_ssl = verify_ssl
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
data = {"text": message}
diff --git a/homeassistant/components/syslog/notify.py b/homeassistant/components/syslog/notify.py
index dbbada65fb2..96102cc9c0a 100644
--- a/homeassistant/components/syslog/notify.py
+++ b/homeassistant/components/syslog/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import syslog
+from typing import Any
import voluptuous as vol
@@ -91,7 +92,7 @@ class SyslogNotificationService(BaseNotificationService):
self._option = option
self._priority = priority
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to syslog."""
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index 28184e459e9..ec8c53f3572 100644
--- a/homeassistant/components/tado/manifest.json
+++ b/homeassistant/components/tado/manifest.json
@@ -15,5 +15,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
- "requirements": ["python-tado==0.18.15"]
+ "requirements": ["python-tado==0.18.16"]
}
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
index 8d42596d3db..8d33705cb67 100644
--- a/homeassistant/components/tag/__init__.py
+++ b/homeassistant/components/tag/__init__.py
@@ -83,8 +83,8 @@ def _create_entry(
DOMAIN,
DOMAIN,
tag_id,
+ object_id_base=slugify(name) if name else tag_id,
original_name=f"{DEFAULT_NAME} {tag_id}",
- suggested_object_id=slugify(name) if name else tag_id,
)
if name:
return entity_registry.async_update_entity(entry.entity_id, name=name)
diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py
index c4a67f67b64..6bd4897939a 100644
--- a/homeassistant/components/telegram/notify.py
+++ b/homeassistant/components/telegram/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import voluptuous as vol
@@ -77,7 +78,7 @@ class TelegramNotificationService(BaseNotificationService):
self._chat_id = chat_id
self.hass = hass
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
data = kwargs.get(ATTR_DATA)
@@ -126,7 +127,7 @@ class TelegramNotificationService(BaseNotificationService):
self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_photo", service_data=service_data
)
- return None
+ return
if data is not None and ATTR_VIDEO in data:
videos = data.get(ATTR_VIDEO)
videos = videos if isinstance(videos, list) else [videos]
@@ -135,7 +136,7 @@ class TelegramNotificationService(BaseNotificationService):
self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_video", service_data=service_data
)
- return None
+ return
if data is not None and ATTR_VOICE in data:
voices = data.get(ATTR_VOICE)
voices = voices if isinstance(voices, list) else [voices]
@@ -144,17 +145,19 @@ class TelegramNotificationService(BaseNotificationService):
self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_voice", service_data=service_data
)
- return None
+ return
if data is not None and ATTR_LOCATION in data:
service_data.update(data.get(ATTR_LOCATION))
- return self.hass.services.call(
+ self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_location", service_data=service_data
)
+ return
if data is not None and ATTR_DOCUMENT in data:
service_data.update(data.get(ATTR_DOCUMENT))
- return self.hass.services.call(
+ self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_document", service_data=service_data
)
+ return
# Send message
@@ -168,6 +171,6 @@ class TelegramNotificationService(BaseNotificationService):
TELEGRAM_BOT_DOMAIN,
service_data,
)
- return self.hass.services.call(
+ self.hass.services.call(
TELEGRAM_BOT_DOMAIN, "send_message", service_data=service_data
)
diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py
index b8640c5c005..d2b95cc9d44 100644
--- a/homeassistant/components/telegram_bot/polling.py
+++ b/homeassistant/components/telegram_bot/polling.py
@@ -24,13 +24,13 @@ async def async_setup_platform(
return pollbot
-async def process_error(update: object, context: CallbackContext) -> None:
+async def process_error(bot: Bot, update: object, context: CallbackContext) -> None:
"""Telegram bot error handler."""
if context.error:
- error_callback(context.error, update)
+ error_callback(bot, context.error, update)
-def error_callback(error: Exception, update: object | None = None) -> None:
+def error_callback(bot: Bot, error: Exception, update: object | None = None) -> None:
"""Log the error."""
try:
raise error
@@ -39,9 +39,17 @@ def error_callback(error: Exception, update: object | None = None) -> None:
pass
except TelegramError:
if update is not None:
- _LOGGER.error('Update "%s" caused error: "%s"', update, error)
+ _LOGGER.error(
+ '[%s %s] Update "%s" caused error: "%s"',
+ bot.username,
+ bot.id,
+ update,
+ error,
+ )
else:
- _LOGGER.error("%s: %s", error.__class__.__name__, error)
+ _LOGGER.error(
+ "[%s %s] %s: %s", bot.username, bot.id, error.__class__.__name__, error
+ )
class PollBot(BaseTelegramBot):
@@ -58,7 +66,9 @@ class PollBot(BaseTelegramBot):
self.bot = bot
self.application = ApplicationBuilder().bot(self.bot).build()
self.application.add_handler(TypeHandler(Update, self.handle_update))
- self.application.add_error_handler(process_error)
+ self.application.add_error_handler(
+ lambda update, context: process_error(self.bot, update, context)
+ )
async def shutdown(self) -> None:
"""Shutdown the app."""
@@ -66,16 +76,18 @@ class PollBot(BaseTelegramBot):
async def start_polling(self) -> None:
"""Start the polling task."""
- _LOGGER.debug("Starting polling")
await self.application.initialize()
if self.application.updater:
- await self.application.updater.start_polling(error_callback=error_callback)
+ await self.application.updater.start_polling(
+ error_callback=lambda error: error_callback(self.bot, error, None)
+ )
await self.application.start()
+ _LOGGER.info("[%s %s] Started polling", self.bot.username, self.bot.id)
async def stop_polling(self) -> None:
"""Stop the polling task."""
- _LOGGER.debug("Stopping polling")
if self.application.updater:
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()
+ _LOGGER.info("[%s %s] Stopped polling", self.bot.username, self.bot.id)
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index 38a5900fcc1..b8b7caf9b71 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -4,7 +4,7 @@ from typing import Final
from aiohttp.client_exceptions import ClientResponseError
import jwt
-from tesla_fleet_api import TeslaFleetApi
+from tesla_fleet_api import TeslaFleetApi, is_valid_region
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
InvalidRegion,
@@ -14,6 +14,7 @@ from tesla_fleet_api.exceptions import (
OAuthExpired,
TeslaFleetError,
)
+from tesla_fleet_api.tesla import VehicleFleet
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
@@ -79,7 +80,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
token = jwt.decode(access_token, options={"verify_signature": False})
scopes: list[Scope] = [Scope(s) for s in token["scp"]]
- region: str = token["ou_code"].lower()
+ region_code = token["ou_code"].lower()
+ region = region_code if is_valid_region(region_code) else None
oauth_session = OAuth2Session(hass, entry, implementation)
@@ -131,14 +133,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
product.pop("cached_data", None)
vin = product["vin"]
signing = product["command_signing"] == "required"
+ api_vehicle: VehicleFleet
if signing:
if not tesla.private_key:
await tesla.get_private_key(hass.config.path("tesla_fleet.key"))
- api = tesla.vehicles.createSigned(vin)
+ api_vehicle = tesla.vehicles.createSigned(vin)
else:
- api = tesla.vehicles.createFleet(vin)
+ api_vehicle = tesla.vehicles.createFleet(vin)
coordinator = TeslaFleetVehicleDataCoordinator(
- hass, entry, api, product, Scope.VEHICLE_LOCATION in scopes
+ hass, entry, api_vehicle, product, Scope.VEHICLE_LOCATION in scopes
)
await coordinator.async_config_entry_first_refresh()
@@ -153,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
vehicles.append(
TeslaFleetVehicleData(
- api=api,
+ api=api_vehicle,
coordinator=coordinator,
vin=vin,
device=device,
@@ -173,14 +176,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
)
continue
- api = tesla.energySites.create(site_id)
+ api_energy = tesla.energySites.create(site_id)
- live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api)
+ live_coordinator = TeslaFleetEnergySiteLiveCoordinator(
+ hass, entry, api_energy
+ )
history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(
- hass, entry, api
+ hass, entry, api_energy
)
info_coordinator = TeslaFleetEnergySiteInfoCoordinator(
- hass, entry, api, product
+ hass, entry, api_energy, product
)
await live_coordinator.async_config_entry_first_refresh()
@@ -214,7 +219,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
energysites.append(
TeslaFleetEnergyData(
- api=api,
+ api=api_energy,
live_coordinator=live_coordinator,
history_coordinator=history_coordinator,
info_coordinator=info_coordinator,
diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py
index 2628a9e134f..627f412a673 100644
--- a/homeassistant/components/tesla_fleet/climate.py
+++ b/homeassistant/components/tesla_fleet/climate.py
@@ -79,7 +79,7 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity):
self,
data: TeslaFleetVehicleData,
side: TeslaFleetClimateSide,
- scopes: Scope,
+ scopes: list[Scope],
) -> None:
"""Initialize the climate."""
@@ -219,7 +219,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn
def __init__(
self,
data: TeslaFleetVehicleData,
- scopes: Scope,
+ scopes: list[Scope],
) -> None:
"""Initialize the cabin overheat climate entity."""
diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py
index 59d1c250703..f875372b8ae 100644
--- a/homeassistant/components/tesla_fleet/coordinator.py
+++ b/homeassistant/components/tesla_fleet/coordinator.py
@@ -178,13 +178,15 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
try:
data = (await self.api.live_status())["response"]
except RateLimited as e:
- LOGGER.warning(
- "%s rate limited, will retry in %s seconds",
- self.name,
- e.data.get("after"),
- )
- if "after" in e.data:
+ if isinstance(e.data, dict) and "after" in e.data:
+ LOGGER.warning(
+ "%s rate limited, will retry in %s seconds",
+ self.name,
+ e.data["after"],
+ )
self.update_interval = timedelta(seconds=int(e.data["after"]))
+ else:
+ LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
@@ -240,13 +242,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
try:
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
except RateLimited as e:
- LOGGER.warning(
- "%s rate limited, will retry in %s seconds",
- self.name,
- e.data.get("after"),
- )
- if "after" in e.data:
+ if isinstance(e.data, dict) and "after" in e.data:
+ LOGGER.warning(
+ "%s rate limited, will retry in %s seconds",
+ self.name,
+ e.data["after"],
+ )
self.update_interval = timedelta(seconds=int(e.data["after"]))
+ else:
+ LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
@@ -303,13 +307,15 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
try:
data = (await self.api.site_info())["response"]
except RateLimited as e:
- LOGGER.warning(
- "%s rate limited, will retry in %s seconds",
- self.name,
- e.data.get("after"),
- )
- if "after" in e.data:
+ if isinstance(e.data, dict) and "after" in e.data:
+ LOGGER.warning(
+ "%s rate limited, will retry in %s seconds",
+ self.name,
+ e.data["after"],
+ )
self.update_interval = timedelta(seconds=int(e.data["after"]))
+ else:
+ LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py
index 583e92595d0..363ae487e84 100644
--- a/homeassistant/components/tesla_fleet/entity.py
+++ b/homeassistant/components/tesla_fleet/entity.py
@@ -1,7 +1,7 @@
"""Tesla Fleet parent entity class."""
from abc import abstractmethod
-from typing import Any
+from typing import Any, Generic, TypeVar
from tesla_fleet_api.const import Scope
from tesla_fleet_api.tesla.energysite import EnergySite
@@ -21,6 +21,8 @@ from .coordinator import (
from .helpers import wake_up_vehicle
from .models import TeslaFleetEnergyData, TeslaFleetVehicleData
+_ApiT = TypeVar("_ApiT", bound=VehicleFleet | EnergySite)
+
class TeslaFleetEntity(
CoordinatorEntity[
@@ -28,13 +30,15 @@ class TeslaFleetEntity(
| TeslaFleetEnergySiteLiveCoordinator
| TeslaFleetEnergySiteHistoryCoordinator
| TeslaFleetEnergySiteInfoCoordinator
- ]
+ ],
+ Generic[_ApiT],
):
"""Parent class for all TeslaFleet entities."""
_attr_has_entity_name = True
read_only: bool
scoped: bool
+ api: _ApiT
def __init__(
self,
@@ -42,7 +46,7 @@ class TeslaFleetEntity(
| TeslaFleetEnergySiteLiveCoordinator
| TeslaFleetEnergySiteHistoryCoordinator
| TeslaFleetEnergySiteInfoCoordinator,
- api: VehicleFleet | EnergySite,
+ api: _ApiT,
key: str,
) -> None:
"""Initialize common aspects of a TeslaFleet entity."""
@@ -100,7 +104,7 @@ class TeslaFleetEntity(
)
-class TeslaFleetVehicleEntity(TeslaFleetEntity):
+class TeslaFleetVehicleEntity(TeslaFleetEntity[VehicleFleet]):
"""Parent class for TeslaFleet Vehicle entities."""
_last_update: int = 0
@@ -128,7 +132,7 @@ class TeslaFleetVehicleEntity(TeslaFleetEntity):
await wake_up_vehicle(self.vehicle)
-class TeslaFleetEnergyLiveEntity(TeslaFleetEntity):
+class TeslaFleetEnergyLiveEntity(TeslaFleetEntity[EnergySite]):
"""Parent class for TeslaFleet Energy Site Live entities."""
def __init__(
@@ -143,7 +147,7 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity):
super().__init__(data.live_coordinator, data.api, key)
-class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity):
+class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity[EnergySite]):
"""Parent class for TeslaFleet Energy Site History entities."""
def __init__(
@@ -158,7 +162,7 @@ class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity):
super().__init__(data.history_coordinator, data.api, key)
-class TeslaFleetEnergyInfoEntity(TeslaFleetEntity):
+class TeslaFleetEnergyInfoEntity(TeslaFleetEntity[EnergySite]):
"""Parent class for TeslaFleet Energy Site Info entities."""
def __init__(
@@ -174,7 +178,7 @@ class TeslaFleetEnergyInfoEntity(TeslaFleetEntity):
class TeslaFleetWallConnectorEntity(
- TeslaFleetEntity, CoordinatorEntity[TeslaFleetEnergySiteLiveCoordinator]
+ TeslaFleetEntity[EnergySite], CoordinatorEntity[TeslaFleetEnergySiteLiveCoordinator]
):
"""Parent class for Tesla Fleet Wall Connector entities."""
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 5e79a091d93..4a1d49a52dd 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==1.3.2"]
+ "requirements": ["tesla-fleet-api==1.4.2"]
}
diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py
index b4f7e42cafd..9d3787775a4 100644
--- a/homeassistant/components/tesla_fleet/number.py
+++ b/homeassistant/components/tesla_fleet/number.py
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription):
"""Describes TeslaFleet Number entity."""
- func: Callable[[VehicleFleet, float], Awaitable[Any]]
+ func: Callable[[VehicleFleet, int], Awaitable[Any]]
native_min_value: float
native_max_value: float
min_key: str | None = None
@@ -74,19 +74,19 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = (
class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription):
"""Describes TeslaFleet Number entity."""
- func: Callable[[EnergySite, float], Awaitable[Any]]
+ func: Callable[[EnergySite, int], Awaitable[Any]]
requires: str | None = None
ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = (
TeslaFleetNumberBatteryEntityDescription(
key="backup_reserve_percent",
- func=lambda api, value: api.backup(int(value)),
+ func=lambda api, value: api.backup(value),
requires="components_battery",
),
TeslaFleetNumberBatteryEntityDescription(
key="off_grid_vehicle_charging_reserve_percent",
- func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)),
+ func=lambda api, value: api.off_grid_vehicle_charging_reserve(value),
requires="components_off_grid_vehicle_charging_reserve_supported",
),
)
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index 5513e2b625c..42d0ac25388 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -2,9 +2,10 @@
import asyncio
from collections.abc import Callable
+from functools import partial
from typing import Final
-from aiohttp import ClientResponseError
+from aiohttp import ClientError, ClientResponseError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -26,6 +27,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
+ ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -74,28 +76,48 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
+async def _get_access_token(oauth_session: OAuth2Session) -> str:
+ """Get a valid access token, refreshing if necessary."""
+ LOGGER.debug(
+ "Token valid: %s, expires_at: %s",
+ oauth_session.valid_token,
+ oauth_session.token.get("expires_at"),
+ )
+ try:
+ await oauth_session.async_ensure_token_valid()
+ except ClientResponseError as err:
+ if err.status == 401:
+ raise ConfigEntryAuthFailed from err
+ raise ConfigEntryNotReady from err
+ except (KeyError, TypeError) as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="token_data_malformed",
+ ) from err
+ except ClientError as err:
+ raise ConfigEntryNotReady from err
+ return oauth_session.token[CONF_ACCESS_TOKEN]
+
+
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
"""Set up Teslemetry config."""
session = async_get_clientsession(hass)
- implementation = await async_get_config_entry_implementation(hass, entry)
+ try:
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ except ImplementationUnavailableError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="oauth_implementation_not_available",
+ ) from err
oauth_session = OAuth2Session(hass, entry, implementation)
- async def _get_access_token() -> str:
- try:
- await oauth_session.async_ensure_token_valid()
- except ClientResponseError as e:
- if e.status == 401:
- raise ConfigEntryAuthFailed from e
- raise ConfigEntryNotReady from e
- token: str = oauth_session.token[CONF_ACCESS_TOKEN]
- return token
-
# Create API connection
+ access_token = partial(_get_access_token, oauth_session)
teslemetry = Teslemetry(
session=session,
- access_token=_get_access_token,
+ access_token=access_token,
)
try:
calls = await asyncio.gather(
@@ -135,14 +157,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
- api = teslemetry.vehicles.create(vin)
- coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product)
+ vehicle = teslemetry.vehicles.create(vin)
+ coordinator = TeslemetryVehicleDataCoordinator(
+ hass, entry, vehicle, product
+ )
device = DeviceInfo(
identifiers={(DOMAIN, vin)},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name=product["display_name"],
- model=api.model,
+ model=vehicle.model,
serial_number=vin,
)
current_devices.add((DOMAIN, vin))
@@ -151,7 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
if not stream:
stream = TeslemetryStream(
session,
- _get_access_token,
+ access_token,
server=f"{region.lower()}.teslemetry.com",
parse_timestamp=True,
manual=True,
@@ -167,7 +191,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
vehicles.append(
TeslemetryVehicleData(
- api=api,
+ api=vehicle,
config_entry=entry,
coordinator=coordinator,
poll=poll,
@@ -193,7 +217,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
)
continue
- api = teslemetry.energySites.create(site_id)
+ energy_site = teslemetry.energySites.create(site_id)
device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
manufacturer="Tesla",
@@ -209,7 +233,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
# Check live status endpoint works before creating its coordinator
try:
- live_status = (await api.live_status())["response"]
+ live_status = (await energy_site.live_status())["response"]
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
@@ -217,19 +241,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
energysites.append(
TeslemetryEnergyData(
- api=api,
+ api=energy_site,
live_coordinator=(
TeslemetryEnergySiteLiveCoordinator(
- hass, entry, api, live_status
+ hass, entry, energy_site, live_status
)
if isinstance(live_status, dict)
else None
),
info_coordinator=TeslemetryEnergySiteInfoCoordinator(
- hass, entry, api, product
+ hass, entry, energy_site, product
),
history_coordinator=(
- TeslemetryEnergyHistoryCoordinator(hass, entry, api)
+ TeslemetryEnergyHistoryCoordinator(hass, entry, energy_site)
if powerwall
else None
),
@@ -313,9 +337,9 @@ async def async_migrate_entry(
# Convert legacy access token to OAuth tokens using migrate endpoint
try:
data = await Teslemetry(session, access_token).migrate_to_oauth(
- CLIENT_ID, access_token, hass.config.location_name
+ CLIENT_ID, hass.config.location_name
)
- except ClientResponseError as e:
+ except (ClientError, TypeError) as e:
raise ConfigEntryAuthFailed from e
# Add auth_implementation for OAuth2 flow compatibility
diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py
index cdcfaaa2289..b1788df589e 100644
--- a/homeassistant/components/teslemetry/config_flow.py
+++ b/homeassistant/components/teslemetry/config_flow.py
@@ -18,7 +18,11 @@ from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
+ ConfigFlowResult,
+)
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -73,6 +77,11 @@ class OAuth2FlowHandler(
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch(reason="reconfigure_account_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data=data
+ )
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -121,3 +130,9 @@ class OAuth2FlowHandler(
)
return await super().async_step_user()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration."""
+ return await self.async_step_user()
diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py
index a73424bdd28..577db30641a 100644
--- a/homeassistant/components/teslemetry/coordinator.py
+++ b/homeassistant/components/teslemetry/coordinator.py
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, Any
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
+ GatewayTimeout,
+ InvalidResponse,
InvalidToken,
+ RateLimited,
+ ServiceUnavailable,
SubscriptionRequired,
TeslaFleetError,
)
@@ -23,6 +27,22 @@ if TYPE_CHECKING:
from .const import ENERGY_HISTORY_FIELDS, LOGGER
from .helpers import flatten
+RETRY_EXCEPTIONS = (
+ InvalidResponse,
+ RateLimited,
+ ServiceUnavailable,
+ GatewayTimeout,
+)
+
+
+def _get_retry_after(e: TeslaFleetError) -> float:
+ """Calculate wait time from exception."""
+ if isinstance(e.data, dict):
+ if after := e.data.get("after"):
+ return float(after)
+ return 10.0
+
+
VEHICLE_INTERVAL = timedelta(seconds=60)
VEHICLE_WAIT = timedelta(minutes=15)
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
@@ -69,14 +89,14 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Teslemetry API."""
-
try:
data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"]
except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
+ except RETRY_EXCEPTIONS as e:
+ raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
-
return flatten(data)
@@ -111,19 +131,18 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
-
try:
data = (await self.api.live_status())["response"]
except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
+ except RETRY_EXCEPTIONS as e:
+ raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
-
# Convert Wall Connectors from array to dict
data["wall_connectors"] = {
wc["din"]: wc for wc in (data.get("wall_connectors") or [])
}
-
return data
@@ -152,14 +171,14 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
-
try:
data = (await self.api.site_info())["response"]
except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
+ except RETRY_EXCEPTIONS as e:
+ raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
-
return flatten(data)
@@ -187,11 +206,12 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
-
try:
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
+ except RETRY_EXCEPTIONS as e:
+ raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index dc942335304..d5399a20e30 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==1.3.2", "teslemetry-stream==0.9.0"]
+ "requirements": ["tesla-fleet-api==1.4.2", "teslemetry-stream==0.9.0"]
}
diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py
index 99d6936ba17..8bbd002897b 100644
--- a/homeassistant/components/teslemetry/services.py
+++ b/homeassistant/components/teslemetry/services.py
@@ -149,7 +149,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
config = async_get_config_for_device(hass, device)
vehicle = async_get_vehicle_for_entry(hass, device, config)
- time: int | None = None
+ time: int
# Convert time to minutes since minute
if "time" in call.data:
(hours, minutes, *_seconds) = call.data["time"].split(":")
@@ -158,6 +158,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="set_scheduled_charging_time"
)
+ else:
+ time = 0
await handle_vehicle_command(
vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time)
@@ -198,6 +200,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
translation_domain=DOMAIN,
translation_key="set_scheduled_departure_preconditioning",
)
+ else:
+ departure_time = 0
# Off peak charging
off_peak_charging_enabled = call.data.get(ATTR_OFF_PEAK_CHARGING_ENABLED, False)
@@ -214,6 +218,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
translation_domain=DOMAIN,
translation_key="set_scheduled_departure_off_peak",
)
+ else:
+ end_off_peak_time = 0
await handle_vehicle_command(
vehicle.api.set_scheduled_departure(
@@ -252,9 +258,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
vehicle = async_get_vehicle_for_entry(hass, device, config)
await handle_vehicle_command(
- vehicle.api.set_valet_mode(
- call.data.get("enable"), call.data.get("pin", "")
- )
+ vehicle.api.set_valet_mode(call.data["enable"], call.data["pin"])
)
hass.services.async_register(
@@ -276,14 +280,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
config = async_get_config_for_device(hass, device)
vehicle = async_get_vehicle_for_entry(hass, device, config)
- enable = call.data.get("enable")
+ enable = call.data["enable"]
if enable is True:
await handle_vehicle_command(
- vehicle.api.speed_limit_activate(call.data.get("pin"))
+ vehicle.api.speed_limit_activate(call.data["pin"])
)
elif enable is False:
await handle_vehicle_command(
- vehicle.api.speed_limit_deactivate(call.data.get("pin"))
+ vehicle.api.speed_limit_deactivate(call.data["pin"])
)
hass.services.async_register(
@@ -306,7 +310,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
site = async_get_energy_site_for_entry(hass, device, config)
resp = await handle_command(
- site.api.time_of_use_settings(call.data.get(ATTR_TOU_SETTINGS))
+ site.api.time_of_use_settings(call.data[ATTR_TOU_SETTINGS])
)
if "error" in resp:
raise HomeAssistantError(
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index 84068107768..dfebeb14f23 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -33,7 +33,9 @@
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_account_mismatch": "The reauthentication account does not match the original account",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_account_mismatch": "The reconfiguration account does not match the original account",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -997,7 +999,6 @@
"total_grid_energy_exported": {
"name": "Grid exported"
},
-
"total_home_usage": {
"name": "Home usage"
},
@@ -1127,6 +1128,21 @@
"no_vehicle_data_for_device": {
"message": "No vehicle data for device ID: {device_id}"
},
+ "oauth_implementation_not_available": {
+ "message": "OAuth implementation not available, try reauthenticating"
+ },
+ "set_scheduled_charging_time": {
+ "message": "Scheduled charging time is required when enabling"
+ },
+ "set_scheduled_departure_off_peak": {
+ "message": "Off-peak charging end time is required when enabling"
+ },
+ "set_scheduled_departure_preconditioning": {
+ "message": "Preconditioning departure time is required when enabling"
+ },
+ "token_data_malformed": {
+ "message": "Token data malformed, try reauthenticating"
+ },
"wake_up_failed": {
"message": "Failed to wake up vehicle: {message}"
},
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 8cf3bcee263..c12bcd5ce10 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
- "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.3.2"]
+ "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.2"]
}
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
index 6bb5c33ceb8..e14a717fcf4 100644
--- a/homeassistant/components/tibber/__init__.py
+++ b/homeassistant/components/tibber/__init__.py
@@ -33,7 +33,7 @@ from .const import (
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
-PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
diff --git a/homeassistant/components/tibber/binary_sensor.py b/homeassistant/components/tibber/binary_sensor.py
new file mode 100644
index 00000000000..d1da82618ca
--- /dev/null
+++ b/homeassistant/components/tibber/binary_sensor.py
@@ -0,0 +1,130 @@
+"""Support for Tibber binary sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+import tibber
+from tibber.data_api import TibberDevice
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, TibberConfigEntry
+from .coordinator import TibberDataAPICoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class TibberBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Tibber binary sensor entity."""
+
+ is_on_fn: Callable[[str], bool | None]
+
+
+DATA_API_BINARY_SENSORS: tuple[TibberBinarySensorEntityDescription, ...] = (
+ TibberBinarySensorEntityDescription(
+ key="connector.status",
+ device_class=BinarySensorDeviceClass.PLUG,
+ is_on_fn={"connected": True, "disconnected": False}.get,
+ ),
+ TibberBinarySensorEntityDescription(
+ key="charging.status",
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ is_on_fn={"charging": True, "idle": False}.get,
+ ),
+ TibberBinarySensorEntityDescription(
+ key="onOff",
+ device_class=BinarySensorDeviceClass.POWER,
+ is_on_fn={"on": True, "off": False}.get,
+ ),
+ TibberBinarySensorEntityDescription(
+ key="isOnline",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ is_on_fn=lambda v: v.lower() == "true",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: TibberConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Tibber binary sensors."""
+ coordinator = entry.runtime_data.data_api_coordinator
+ assert coordinator is not None
+
+ entities: list[TibberDataAPIBinarySensor] = []
+ api_binary_sensors = {sensor.key: sensor for sensor in DATA_API_BINARY_SENSORS}
+
+ for device in coordinator.data.values():
+ for sensor in device.sensors:
+ description: TibberBinarySensorEntityDescription | None = (
+ api_binary_sensors.get(sensor.id)
+ )
+ if description is None:
+ continue
+ entities.append(TibberDataAPIBinarySensor(coordinator, device, description))
+ async_add_entities(entities)
+
+
+class TibberDataAPIBinarySensor(
+ CoordinatorEntity[TibberDataAPICoordinator], BinarySensorEntity
+):
+ """Representation of a Tibber Data API binary sensor."""
+
+ _attr_has_entity_name = True
+ entity_description: TibberBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: TibberDataAPICoordinator,
+ device: TibberDevice,
+ entity_description: TibberBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize the binary sensor."""
+ super().__init__(coordinator)
+
+ self._device_id: str = device.id
+ self.entity_description = entity_description
+
+ self._attr_unique_id = f"{device.id}_{entity_description.key}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.external_id)},
+ name=device.name,
+ manufacturer=device.brand,
+ model=device.model,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return (
+ super().available and self._device_id in self.coordinator.sensors_by_device
+ )
+
+ @property
+ def device(self) -> dict[str, tibber.data_api.Sensor]:
+ """Return the device sensors."""
+ return self.coordinator.sensors_by_device[self._device_id]
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the state of the binary sensor."""
+ return self.entity_description.is_on_fn(
+ str(self.device[self.entity_description.key].value)
+ )
diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py
index 84fac8237c0..39fca55238c 100644
--- a/homeassistant/components/tibber/coordinator.py
+++ b/homeassistant/components/tibber/coordinator.py
@@ -250,6 +250,12 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
- devices: dict[str, TibberDevice] = await client.update_devices()
+ try:
+ devices: dict[str, TibberDevice] = await client.update_devices()
+ except tibber.exceptions.RateLimitExceededError as err:
+ raise UpdateFailed(
+ f"Rate limit exceeded, retry after {err.retry_after} seconds",
+ retry_after=err.retry_after,
+ ) from err
self._build_sensor_lookup(devices)
return devices
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index 9388d413c04..d44a6b64008 100644
--- a/homeassistant/components/tibber/manifest.json
+++ b/homeassistant/components/tibber/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
- "requirements": ["pyTibber==0.34.1"]
+ "requirements": ["pyTibber==0.35.0"]
}
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index 778e7914d24..5c3632c32d1 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -264,6 +264,15 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="cellular.rssi",
+ translation_key="cellular_rssi",
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
@@ -278,6 +287,254 @@ DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
+ SensorEntityDescription(
+ key="storage.ratedCapacity",
+ translation_key="storage_rated_capacity",
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="storage.ratedPower",
+ translation_key="storage_rated_power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SensorEntityDescription(
+ key="storage.availableEnergy",
+ translation_key="storage_available_energy",
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.battery.power",
+ translation_key="power_flow_battery",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.grid.power",
+ translation_key="power_flow_grid",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.load.power",
+ translation_key="power_flow_load",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.toGrid",
+ translation_key="power_flow_to_grid",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.toLoad",
+ translation_key="power_flow_to_load",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.fromGrid",
+ translation_key="power_flow_from_grid",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="powerFlow.fromLoad",
+ translation_key="power_flow_from_load",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.battery.charged",
+ translation_key="energy_flow_hour_battery_charged",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.battery.discharged",
+ translation_key="energy_flow_hour_battery_discharged",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.battery.source.grid",
+ translation_key="energy_flow_hour_battery_source_grid",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.battery.source.load",
+ translation_key="energy_flow_hour_battery_source_load",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.grid.imported",
+ translation_key="energy_flow_hour_grid_imported",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.grid.exported",
+ translation_key="energy_flow_hour_grid_exported",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.load.consumed",
+ translation_key="energy_flow_hour_load_consumed",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.load.generated",
+ translation_key="energy_flow_hour_load_generated",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.load.source.battery",
+ translation_key="energy_flow_hour_load_source_battery",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.hour.load.source.grid",
+ translation_key="energy_flow_hour_load_source_grid",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.battery.charged",
+ translation_key="energy_flow_month_battery_charged",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.battery.discharged",
+ translation_key="energy_flow_month_battery_discharged",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.battery.source.grid",
+ translation_key="energy_flow_month_battery_source_grid",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.battery.source.battery",
+ translation_key="energy_flow_month_battery_source_battery",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.battery.source.load",
+ translation_key="energy_flow_month_battery_source_load",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.grid.imported",
+ translation_key="energy_flow_month_grid_imported",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.grid.exported",
+ translation_key="energy_flow_month_grid_exported",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.grid.source.battery",
+ translation_key="energy_flow_month_grid_source_battery",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.grid.source.grid",
+ translation_key="energy_flow_month_grid_source_grid",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.grid.source.load",
+ translation_key="energy_flow_month_grid_source_load",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.load.consumed",
+ translation_key="energy_flow_month_load_consumed",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.load.generated",
+ translation_key="energy_flow_month_load_generated",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.load.source.battery",
+ translation_key="energy_flow_month_load_source_battery",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
+ SensorEntityDescription(
+ key="energyFlow.month.load.source.grid",
+ translation_key="energy_flow_month_load_source_grid",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_registry_enabled_default=False,
+ ),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
@@ -351,7 +608,6 @@ async def _async_setup_graphql_sensors(
tibber_connection = entry.runtime_data.tibber_connection
entity_registry = er.async_get(hass)
- device_registry = dr.async_get(hass)
coordinator: TibberDataCoordinator | None = None
entities: list[TibberSensor] = []
@@ -391,25 +647,6 @@ async def _async_setup_graphql_sensors(
).async_set_updated_data
)
- # migrate
- old_id = home.info["viewer"]["home"]["meteringPointData"]["consumptionEan"]
- if old_id is None:
- continue
-
- # migrate to new device ids
- old_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, old_id)
- if old_entity_id is not None:
- entity_registry.async_update_entity(
- old_entity_id, new_unique_id=home.home_id
- )
-
- # migrate to new device ids
- device_entry = device_registry.async_get_device(identifiers={(DOMAIN, old_id)})
- if device_entry and entry.entry_id in device_entry.config_entries:
- device_registry.async_update_device(
- device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
- )
-
async_add_entities(entities)
@@ -430,9 +667,6 @@ def _setup_data_api_sensors(
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
- _LOGGER.debug(
- "Sensor %s not found in DATA_API_SENSORS, skipping", sensor
- )
continue
entities.append(TibberDataAPISensor(coordinator, device, description))
async_add_entities(entities)
diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json
index 4212847379e..1e6011381e3 100644
--- a/homeassistant/components/tibber/strings.json
+++ b/homeassistant/components/tibber/strings.json
@@ -48,6 +48,9 @@
"average_power": {
"name": "Average power"
},
+ "cellular_rssi": {
+ "name": "Cellular signal strength"
+ },
"charging_current_max": {
"name": "Maximum allowed charge current"
},
@@ -66,6 +69,72 @@
"electricity_price": {
"name": "Electricity price"
},
+ "energy_flow_hour_battery_charged": {
+ "name": "Battery energy charged this hour"
+ },
+ "energy_flow_hour_battery_discharged": {
+ "name": "Battery energy discharged this hour"
+ },
+ "energy_flow_hour_battery_source_grid": {
+ "name": "Battery charged from grid this hour"
+ },
+ "energy_flow_hour_battery_source_load": {
+ "name": "Battery charged from load this hour"
+ },
+ "energy_flow_hour_grid_exported": {
+ "name": "Energy exported to grid this hour"
+ },
+ "energy_flow_hour_grid_imported": {
+ "name": "Energy imported from grid this hour"
+ },
+ "energy_flow_hour_load_consumed": {
+ "name": "Load energy consumed this hour"
+ },
+ "energy_flow_hour_load_generated": {
+ "name": "Load energy generated this hour"
+ },
+ "energy_flow_hour_load_source_battery": {
+ "name": "Load supplied by battery this hour"
+ },
+ "energy_flow_hour_load_source_grid": {
+ "name": "Load supplied by grid this hour"
+ },
+ "energy_flow_month_battery_charged": {
+ "name": "Battery energy charged this month"
+ },
+ "energy_flow_month_battery_discharged": {
+ "name": "Battery energy discharged this month"
+ },
+ "energy_flow_month_battery_source_grid": {
+ "name": "Battery charged from grid this month"
+ },
+ "energy_flow_month_battery_source_load": {
+ "name": "Battery charged from load this month"
+ },
+ "energy_flow_month_grid_exported": {
+ "name": "Energy exported to grid this month"
+ },
+ "energy_flow_month_grid_imported": {
+ "name": "Energy imported from grid this month"
+ },
+ "energy_flow_month_grid_source_battery": {
+ "name": "Grid export from battery this month"
+ },
+ "energy_flow_month_grid_source_load": {
+ "name": "Grid export from load this month"
+ },
+ "energy_flow_month_load_consumed": {
+ "name": "Load energy consumed this month"
+ },
+ "energy_flow_month_load_generated": {
+ "name": "Load energy generated this month"
+ },
+ "energy_flow_month_load_source_battery": {
+ "name": "Load supplied by battery this month"
+ },
+ "energy_flow_month_load_source_grid": {
+ "name": "Load supplied by grid this month"
+ },
"estimated_hour_consumption": {
"name": "Estimated consumption current hour"
},
@@ -102,6 +171,27 @@
"power_factor": {
"name": "Power factor"
},
+ "power_flow_battery": {
+ "name": "Battery power"
+ },
+ "power_flow_from_grid": {
+ "name": "Power flow from grid"
+ },
+ "power_flow_from_load": {
+ "name": "Power flow from load"
+ },
+ "power_flow_grid": {
+ "name": "Grid power"
+ },
+ "power_flow_load": {
+ "name": "Load power"
+ },
+ "power_flow_to_grid": {
+ "name": "Power flow to grid"
+ },
+ "power_flow_to_load": {
+ "name": "Power flow to load"
+ },
"power_production": {
"name": "Power production"
},
@@ -111,6 +201,15 @@
"signal_strength": {
"name": "Signal strength"
},
+ "storage_available_energy": {
+ "name": "Available energy"
+ },
+ "storage_rated_capacity": {
+ "name": "Rated capacity"
+ },
+ "storage_rated_power": {
+ "name": "Rated power"
+ },
"storage_state_of_charge": {
"name": "State of charge"
},
diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml
index cc14e6332f1..b5700f8d268 100644
--- a/homeassistant/components/timer/services.yaml
+++ b/homeassistant/components/timer/services.yaml
@@ -35,6 +35,7 @@ change:
required: true
example: "00:01:00, 60 or -60"
selector:
- text:
+ duration:
+ allow_negative: true
reload:
diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json
index 7e9cd726f9d..3cf6b78d4c4 100644
--- a/homeassistant/components/tplink_omada/manifest.json
+++ b/homeassistant/components/tplink_omada/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"iot_class": "local_polling",
+ "quality_scale": "bronze",
"requirements": ["tplink-omada-client==1.5.3"]
}
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index afd0f16b42a..70c517a3cc3 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -165,7 +165,7 @@ class DeviceListener(SharingDeviceListener):
self,
device: CustomerDevice,
updated_status_properties: list[str] | None = None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None = None,
) -> None:
"""Update device status with optional DP timestamps."""
LOGGER.debug(
diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py
index f416f33e468..f123c54a5ae 100644
--- a/homeassistant/components/tuya/binary_sensor.py
+++ b/homeassistant/components/tuya/binary_sensor.py
@@ -471,9 +471,11 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
index 583a11a5318..ddfd874c9cb 100644
--- a/homeassistant/components/tuya/climate.py
+++ b/homeassistant/components/tuya/climate.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import collections
from dataclasses import dataclass
from typing import Any, Self
@@ -38,6 +39,7 @@ from .models import (
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
+from .type_information import EnumTypeInformation
TUYA_HVAC_TO_HA = {
"auto": HVACMode.HEAT_COOL,
@@ -139,6 +141,72 @@ class _SwingModeWrapper(DeviceWrapper):
return commands
+def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
+ """Filter TUYA_HVAC_TO_HA modes that are not in the range.
+
+ If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid
+ ambiguity when converting back from HA to Tuya modes.
+ """
+ modes_in_range = {
+ tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range
+ }
+ modes_occurrences = collections.Counter(modes_in_range.values())
+ for key, value in modes_in_range.items():
+ if value is not None and modes_occurrences[value] > 1:
+ modes_in_range[key] = None
+ return modes_in_range
+
+
+class _HvacModeWrapper(DPCodeEnumWrapper):
+ """Wrapper for managing climate HVACMode."""
+
+ # Modes that do not map to HVAC modes are ignored (they are handled by PresetWrapper)
+
+ def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
+ """Init _HvacModeWrapper."""
+ super().__init__(dpcode, type_information)
+ self._mappings = _filter_hvac_mode_mappings(type_information.range)
+ self.options = [
+ ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
+ ]
+
+ def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
+ """Read the device status."""
+ if (raw := super().read_device_status(device)) not in TUYA_HVAC_TO_HA:
+ return None
+ return TUYA_HVAC_TO_HA[raw]
+
+ def _convert_value_to_raw_value(
+ self, device: CustomerDevice, value: HVACMode
+ ) -> Any:
+ """Convert value to raw value."""
+ return next(
+ tuya_mode
+ for tuya_mode, ha_mode in self._mappings.items()
+ if ha_mode == value
+ )
+
+
+class _PresetWrapper(DPCodeEnumWrapper):
+ """Wrapper for managing climate preset modes."""
+
+ # Modes that map to HVAC modes are ignored (they are handled by HVACModeWrapper)
+
+ def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
+ """Init _PresetWrapper."""
+ super().__init__(dpcode, type_information)
+ mappings = _filter_hvac_mode_mappings(type_information.range)
+ self.options = [
+ tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None
+ ]
+
+ def read_device_status(self, device: CustomerDevice) -> str | None:
+ """Read the device status."""
+ if (raw := super().read_device_status(device)) in TUYA_HVAC_TO_HA:
+ return None
+ return raw
+
+
@dataclass(frozen=True, kw_only=True)
class TuyaClimateEntityDescription(ClimateEntityDescription):
"""Describe an Tuya climate entity."""
@@ -296,7 +364,10 @@ async def async_setup_entry(
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
prefer_function=True,
),
- hvac_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
+ hvac_mode_wrapper=_HvacModeWrapper.find_dpcode(
+ device, DPCode.MODE, prefer_function=True
+ ),
+ preset_wrapper=_PresetWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
set_temperature_wrapper=temperature_wrappers[1],
@@ -322,7 +393,6 @@ async def async_setup_entry(
class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Tuya Climate Device."""
- _hvac_to_tuya: dict[str, str]
entity_description: TuyaClimateEntityDescription
_attr_name = None
@@ -335,7 +405,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
current_humidity_wrapper: DeviceWrapper[int] | None,
current_temperature_wrapper: DeviceWrapper[float] | None,
fan_mode_wrapper: DeviceWrapper[str] | None,
- hvac_mode_wrapper: DeviceWrapper[str] | None,
+ hvac_mode_wrapper: DeviceWrapper[HVACMode] | None,
+ preset_wrapper: DeviceWrapper[str] | None,
set_temperature_wrapper: DeviceWrapper[float] | None,
swing_wrapper: DeviceWrapper[str] | None,
switch_wrapper: DeviceWrapper[bool] | None,
@@ -351,6 +422,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._current_temperature = current_temperature_wrapper
self._fan_mode_wrapper = fan_mode_wrapper
self._hvac_mode_wrapper = hvac_mode_wrapper
+ self._preset_wrapper = preset_wrapper
self._set_temperature = set_temperature_wrapper
self._swing_wrapper = swing_wrapper
self._switch_wrapper = switch_wrapper
@@ -366,29 +438,24 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._attr_target_temperature_step = set_temperature_wrapper.value_step
# Determine HVAC modes
- self._attr_hvac_modes: list[HVACMode] = []
- self._hvac_to_tuya = {}
+ self._attr_hvac_modes = []
if hvac_mode_wrapper:
self._attr_hvac_modes = [HVACMode.OFF]
- unknown_hvac_modes: list[str] = []
- for tuya_mode in hvac_mode_wrapper.options:
- if tuya_mode in TUYA_HVAC_TO_HA:
- ha_mode = TUYA_HVAC_TO_HA[tuya_mode]
- self._hvac_to_tuya[ha_mode] = tuya_mode
- self._attr_hvac_modes.append(ha_mode)
- else:
- unknown_hvac_modes.append(tuya_mode)
+ for mode in hvac_mode_wrapper.options:
+ self._attr_hvac_modes.append(HVACMode(mode))
- if unknown_hvac_modes: # Tuya modes are presets instead of hvac_modes
- self._attr_hvac_modes.append(description.switch_only_hvac_mode)
- self._attr_preset_modes = unknown_hvac_modes
- self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
elif switch_wrapper:
self._attr_hvac_modes = [
HVACMode.OFF,
description.switch_only_hvac_mode,
]
+ # Determine preset modes (ignore if empty options)
+ if preset_wrapper and preset_wrapper.options:
+ self._attr_hvac_modes.append(description.switch_only_hvac_mode)
+ self._attr_preset_modes = preset_wrapper.options
+ self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
+
# Determine dpcode to use for setting the humidity
if target_humidity_wrapper:
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
@@ -419,17 +486,15 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self.device, hvac_mode != HVACMode.OFF
)
)
- if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
+ if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options:
commands.extend(
- self._hvac_mode_wrapper.get_update_commands(
- self.device, self._hvac_to_tuya[hvac_mode]
- )
+ self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode)
)
await self._async_send_commands(commands)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
- await self._async_send_wrapper_updates(self._hvac_mode_wrapper, preset_mode)
+ await self._async_send_wrapper_updates(self._preset_wrapper, preset_mode)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
@@ -484,21 +549,12 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
return None
# If we do have a mode wrapper, check if the mode maps to an HVAC mode.
- if (hvac_status := self._read_wrapper(self._hvac_mode_wrapper)) is None:
- return None
- return TUYA_HVAC_TO_HA.get(hvac_status)
+ return self._read_wrapper(self._hvac_mode_wrapper)
@property
def preset_mode(self) -> str | None:
"""Return preset mode."""
- if self._hvac_mode_wrapper is None:
- return None
-
- mode = self._read_wrapper(self._hvac_mode_wrapper)
- if mode in TUYA_HVAC_TO_HA:
- return None
-
- return mode
+ return self._read_wrapper(self._preset_wrapper)
@property
def fan_mode(self) -> str | None:
diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py
index 49848422c04..c6cc76c22cf 100644
--- a/homeassistant/components/tuya/entity.py
+++ b/homeassistant/components/tuya/entity.py
@@ -57,7 +57,7 @@ class TuyaEntity(Entity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py
index de754f15a47..4ac2c269fa3 100644
--- a/homeassistant/components/tuya/event.py
+++ b/homeassistant/components/tuya/event.py
@@ -218,10 +218,10 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
if self._dpcode_wrapper.skip_update(
- self.device, updated_status_properties
+ self.device, updated_status_properties, dp_timestamps
) or not (event_data := self._dpcode_wrapper.read_device_status(self.device)):
return
diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py
index a7234e36882..747a5676e00 100644
--- a/homeassistant/components/tuya/models.py
+++ b/homeassistant/components/tuya/models.py
@@ -31,7 +31,10 @@ class DeviceWrapper[T]:
options: list[str]
def skip_update(
- self, device: CustomerDevice, updated_status_properties: list[str] | None
+ self,
+ device: CustomerDevice,
+ updated_status_properties: list[str] | None,
+ dp_timestamps: dict[str, int] | None,
) -> bool:
"""Determine if the wrapper should skip an update.
@@ -62,7 +65,10 @@ class DPCodeWrapper(DeviceWrapper):
self.dpcode = dpcode
def skip_update(
- self, device: CustomerDevice, updated_status_properties: list[str] | None
+ self,
+ device: CustomerDevice,
+ updated_status_properties: list[str] | None,
+ dp_timestamps: dict[str, int] | None,
) -> bool:
"""Determine if the wrapper should skip an update.
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index 13ab3472aa8..a8534f4c489 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -554,10 +554,12 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index ac61b46e919..8e884d47cf7 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -410,10 +410,12 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index f5e51a28090..0c4a120c5cf 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -1853,9 +1853,11 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 4adc42adead..4bd803b19a0 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -110,10 +110,12 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index 50b4712bc2b..dce5fec0ef0 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -1043,10 +1043,12 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py
index be2bd905bb1..01bf0f054f6 100644
--- a/homeassistant/components/tuya/valve.py
+++ b/homeassistant/components/tuya/valve.py
@@ -140,10 +140,12 @@ class TuyaValveEntity(TuyaEntity, ValveEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
- dp_timestamps: dict | None = None,
+ dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
- if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
+ if self._dpcode_wrapper.skip_update(
+ self.device, updated_status_properties, dp_timestamps
+ ):
return
self.async_write_ha_state()
diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py
index 4c432e0aeb5..bcea6d6fb82 100644
--- a/homeassistant/components/twilio_call/notify.py
+++ b/homeassistant/components/twilio_call/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import urllib
from twilio.base.exceptions import TwilioRestException
@@ -50,7 +51,7 @@ class TwilioCallNotificationService(BaseNotificationService):
self.client = twilio_client
self.from_number = from_number
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Call to specified target users."""
if not (targets := kwargs.get(ATTR_TARGET)):
_LOGGER.warning("At least 1 target is required")
diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py
index a3f824f375f..24527fdaf53 100644
--- a/homeassistant/components/twilio_sms/notify.py
+++ b/homeassistant/components/twilio_sms/notify.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Any
import voluptuous as vol
@@ -56,7 +57,7 @@ class TwilioSMSNotificationService(BaseNotificationService):
self.client = twilio_client
self.from_number = from_number
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send SMS to specified target user cell."""
targets = kwargs.get(ATTR_TARGET)
data = kwargs.get(ATTR_DATA) or {}
diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json
index 4a3566325a5..8c18fad01ce 100644
--- a/homeassistant/components/twitch/strings.json
+++ b/homeassistant/components/twitch/strings.json
@@ -10,6 +10,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "Wrong account: Please authenticate with {username}."
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"reauth_confirm": {
"description": "The Twitch integration needs to re-authenticate your account",
diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py
index f94bcd54459..7799cfbb85e 100644
--- a/homeassistant/components/twitter/notify.py
+++ b/homeassistant/components/twitter/notify.py
@@ -9,6 +9,7 @@ import json
import logging
import mimetypes
import os
+from typing import Any
from TwitterAPI import TwitterAPI
import voluptuous as vol
@@ -79,7 +80,7 @@ class TwitterNotificationService(BaseNotificationService):
consumer_key, consumer_secret, access_token_key, access_token_secret
)
- def send_message(self, message="", **kwargs):
+ def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Tweet a message, optionally with media."""
data = kwargs.get(ATTR_DATA)
targets = kwargs.get(ATTR_TARGET)
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index 5ba6b39bcd9..b21340d9e4e 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
- "requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==10.0.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py
index 26ee052f6dd..ab21d0a8670 100644
--- a/homeassistant/components/unifiprotect/number.py
+++ b/homeassistant/components/unifiprotect/number.py
@@ -5,9 +5,11 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import timedelta
+import logging
from uiprotect.data import (
Camera,
+ Chime,
Doorlock,
Light,
ModelType,
@@ -30,6 +32,8 @@ from .entity import (
)
from .utils import async_ufp_instance_command
+_LOGGER = logging.getLogger(__name__)
+
PARALLEL_UPDATES = 0
@@ -245,6 +249,51 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
}
+def _async_chime_ring_volume_entities(
+ data: ProtectData,
+ chime: Chime,
+) -> list[ChimeRingVolumeNumber]:
+ """Generate ring volume entities for each paired camera on a chime."""
+ entities: list[ChimeRingVolumeNumber] = []
+
+ if not chime.is_adopted_by_us:
+ return entities
+
+ auth_user = data.api.bootstrap.auth_user
+ if not chime.can_write(auth_user):
+ return entities
+
+ for ring_setting in chime.ring_settings:
+ camera = data.api.bootstrap.cameras.get(ring_setting.camera_id)
+ if camera is None:
+ _LOGGER.debug(
+ "Camera %s not found for chime %s ring volume",
+ ring_setting.camera_id,
+ chime.display_name,
+ )
+ continue
+ entities.append(ChimeRingVolumeNumber(data, chime, camera))
+
+ return entities
+
+
+def _async_all_chime_ring_volume_entities(
+ data: ProtectData,
+ chime: Chime | None = None,
+) -> list[ChimeRingVolumeNumber]:
+ """Generate all ring volume entities for chimes."""
+ entities: list[ChimeRingVolumeNumber] = []
+
+ if chime is not None:
+ return _async_chime_ring_volume_entities(data, chime)
+
+ for device in data.get_by_types({ModelType.CHIME}):
+ if isinstance(device, Chime):
+ entities.extend(_async_chime_ring_volume_entities(data, device))
+
+ return entities
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
@@ -255,23 +304,26 @@ async def async_setup_entry(
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
- async_add_entities(
- async_all_device_entities(
- data,
- ProtectNumbers,
- model_descriptions=_MODEL_DESCRIPTIONS,
- ufp_device=device,
- )
- )
-
- data.async_subscribe_adopt(_add_new_device)
- async_add_entities(
- async_all_device_entities(
+ entities = async_all_device_entities(
data,
ProtectNumbers,
model_descriptions=_MODEL_DESCRIPTIONS,
+ ufp_device=device,
)
+ # Add ring volume entities for chimes
+ if isinstance(device, Chime):
+ entities += _async_all_chime_ring_volume_entities(data, device)
+ async_add_entities(entities)
+
+ data.async_subscribe_adopt(_add_new_device)
+ entities = async_all_device_entities(
+ data,
+ ProtectNumbers,
+ model_descriptions=_MODEL_DESCRIPTIONS,
)
+ # Add ring volume entities for all chimes
+ entities += _async_all_chime_ring_volume_entities(data)
+ async_add_entities(entities)
class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
@@ -302,3 +354,62 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.entity_description.ufp_set(self.device, value)
+
+
+class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity):
+ """A UniFi Protect Number Entity for ring volume per camera on a chime."""
+
+ device: Chime
+ _state_attrs = ("_attr_available", "_attr_native_value")
+ _attr_native_max_value: float = 100
+ _attr_native_min_value: float = 0
+ _attr_native_step: float = 1
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_entity_category = EntityCategory.CONFIG
+
+ def __init__(
+ self,
+ data: ProtectData,
+ chime: Chime,
+ camera: Camera,
+ ) -> None:
+ """Initialize the ring volume number entity."""
+ self._camera_id = camera.id
+ # Use chime MAC and camera ID for unique ID
+ super().__init__(data, chime)
+ self._attr_unique_id = f"{chime.mac}_ring_volume_{camera.id}"
+ self._attr_translation_key = "chime_ring_volume"
+ self._attr_translation_placeholders = {"camera_name": camera.display_name}
+ # BaseProtectEntity sets _attr_name = None when no description is passed,
+ # which prevents translation_key from being used. Delete to enable translations.
+ del self._attr_name
+
+ @callback
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ """Update entity from protect device."""
+ super()._async_update_device_from_protect(device)
+ self._attr_native_value = self._get_ring_volume()
+
+ def _get_ring_volume(self) -> int | None:
+ """Get the ring volume for this camera from the chime's ring settings."""
+ for ring_setting in self.device.ring_settings:
+ if ring_setting.camera_id == self._camera_id:
+ return ring_setting.volume
+ return None
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ # Entity is unavailable if the camera is no longer paired with the chime
+ return super().available and self._get_ring_volume() is not None
+
+ @async_ufp_instance_command
+ async def async_set_native_value(self, value: float) -> None:
+ """Set new ring volume value."""
+ camera = self.data.api.bootstrap.cameras.get(self._camera_id)
+ if camera is None:
+ _LOGGER.warning(
+ "Cannot set ring volume: camera %s not found", self._camera_id
+ )
+ return
+ await self.device.set_volume_for_camera_public(camera, int(value))
diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json
index 0ebe3b5dd14..0d9812abcd3 100644
--- a/homeassistant/components/unifiprotect/strings.json
+++ b/homeassistant/components/unifiprotect/strings.json
@@ -323,6 +323,9 @@
"chime_duration": {
"name": "Chime duration"
},
+ "chime_ring_volume": {
+ "name": "Ring volume ({camera_name})"
+ },
"doorbell_ring_volume": {
"name": "Doorbell ring volume"
},
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
index a79881d3983..834e148a136 100644
--- a/homeassistant/components/utility_meter/__init__.py
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -9,8 +9,8 @@ import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
-from homeassistant.core import HomeAssistant, split_entity_id
+from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
discovery,
@@ -20,7 +20,6 @@ from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
-from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -44,9 +43,8 @@ from .const import (
DATA_UTILITY,
DOMAIN,
METER_TYPES,
- SERVICE_RESET,
- SIGNAL_RESET_METER,
)
+from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -120,27 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up an Utility Meter."""
hass.data[DATA_UTILITY] = {}
- async def async_reset_meters(service_call):
- """Reset all sensors of a meter."""
- meters = service_call.data["entity_id"]
-
- for meter in meters:
- _LOGGER.debug("resetting meter %s", meter)
- domain, entity = split_entity_id(meter)
- # backward compatibility up to 2022.07:
- if domain == DOMAIN:
- async_dispatcher_send(
- hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
- )
- else:
- async_dispatcher_send(hass, SIGNAL_RESET_METER, meter)
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_RESET,
- async_reset_meters,
- vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
- )
+ async_setup_services(hass)
if DOMAIN not in config:
return True
@@ -250,7 +228,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
else:
# Create tariff selection + one meter sensor for each tariff
entity_entry = entity_registry.async_get_or_create(
- Platform.SELECT, DOMAIN, entry.entry_id, suggested_object_id=entry.title
+ Platform.SELECT, DOMAIN, entry.entry_id, object_id_base=entry.title
)
hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = (
entity_entry.entity_id
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 5ab476edecb..071488dc68b 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -688,7 +688,7 @@ class UtilityMeterSensor(RestoreSensor):
self._collecting = None
@property
- def device_class(self):
+ def device_class(self) -> SensorDeviceClass | None:
"""Return the device class of the sensor."""
if self._input_device_class is not None:
return self._input_device_class
@@ -700,7 +700,7 @@ class UtilityMeterSensor(RestoreSensor):
return None
@property
- def state_class(self):
+ def state_class(self) -> SensorStateClass:
"""Return the device class of the sensor."""
return (
SensorStateClass.TOTAL
diff --git a/homeassistant/components/utility_meter/services.py b/homeassistant/components/utility_meter/services.py
new file mode 100644
index 00000000000..ce2755f46ce
--- /dev/null
+++ b/homeassistant/components/utility_meter/services.py
@@ -0,0 +1,43 @@
+"""Support for tracking consumption over given periods of time."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import DOMAIN, SERVICE_RESET, SIGNAL_RESET_METER
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_reset_meters(service_call: ServiceCall) -> None:
+ """Reset all sensors of a meter."""
+ meters = service_call.data["entity_id"]
+
+ for meter in meters:
+ _LOGGER.debug("resetting meter %s", meter)
+ domain, entity = split_entity_id(meter)
+ # backward compatibility up to 2022.07:
+ if domain == DOMAIN:
+ async_dispatcher_send(
+ service_call.hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
+ )
+ else:
+ async_dispatcher_send(service_call.hass, SIGNAL_RESET_METER, meter)
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up the services."""
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_RESET,
+ async_reset_meters,
+ vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
+ )
diff --git a/homeassistant/components/vacuum/icons.json b/homeassistant/components/vacuum/icons.json
index 7ccf5c4715a..73027b0ea8f 100644
--- a/homeassistant/components/vacuum/icons.json
+++ b/homeassistant/components/vacuum/icons.json
@@ -44,7 +44,7 @@
},
"triggers": {
"docked": {
- "trigger": "mdi:home-import-outline"
+ "trigger": "mdi:home-outline"
},
"errored": {
"trigger": "mdi:alert-circle-outline"
@@ -54,6 +54,9 @@
},
"started_cleaning": {
"trigger": "mdi:play"
+ },
+ "started_returning": {
+ "trigger": "mdi:home-import-outline"
}
}
}
diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json
index 44875978e4a..6519d28cb50 100644
--- a/homeassistant/components/vacuum/strings.json
+++ b/homeassistant/components/vacuum/strings.json
@@ -113,7 +113,7 @@
"title": "Vacuum",
"triggers": {
"docked": {
- "description": "Triggers after one or more vacuums return to dock.",
+ "description": "Triggers after one or more vacuums have returned to dock.",
"fields": {
"behavior": {
"description": "[%key:component::vacuum::common::trigger_behavior_description%]",
@@ -151,6 +151,16 @@
}
},
"name": "Vacuum cleaner started cleaning"
+ },
+ "started_returning": {
+ "description": "Triggers after one or more vacuums start returning to dock.",
+ "fields": {
+ "behavior": {
+ "description": "[%key:component::vacuum::common::trigger_behavior_description%]",
+ "name": "[%key:component::vacuum::common::trigger_behavior_name%]"
+ }
+ },
+ "name": "Vacuum cleaner started returning to dock"
}
}
}
diff --git a/homeassistant/components/vacuum/trigger.py b/homeassistant/components/vacuum/trigger.py
index 50ca8af7d47..cbd5d06df0a 100644
--- a/homeassistant/components/vacuum/trigger.py
+++ b/homeassistant/components/vacuum/trigger.py
@@ -12,6 +12,9 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_cleaning": make_entity_target_state_trigger(
DOMAIN, VacuumActivity.CLEANING
),
+ "started_returning": make_entity_target_state_trigger(
+ DOMAIN, VacuumActivity.RETURNING
+ ),
}
diff --git a/homeassistant/components/vacuum/triggers.yaml b/homeassistant/components/vacuum/triggers.yaml
index d4f5fa582b8..e0266db92bc 100644
--- a/homeassistant/components/vacuum/triggers.yaml
+++ b/homeassistant/components/vacuum/triggers.yaml
@@ -18,3 +18,4 @@ docked: *trigger_common
errored: *trigger_common
paused_cleaning: *trigger_common
started_cleaning: *trigger_common
+started_returning: *trigger_common
diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json
index bbc806d8f38..9cb3c739825 100644
--- a/homeassistant/components/vallox/manifest.json
+++ b/homeassistant/components/vallox/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/vallox",
"iot_class": "local_polling",
"loggers": ["vallox_websocket_api"],
- "requirements": ["vallox-websocket-api==5.3.0"]
+ "requirements": ["vallox-websocket-api==6.0.0"]
}
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index 603d82cbf7f..deb053eebc7 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -282,8 +282,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
self._attr_target_temperature = temp
@property
- def preset_mode(self):
+ def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
+ if self._current_program is None:
+ return None
return HeatingProgram.to_ha_preset(self._current_program)
def set_preset_mode(self, preset_mode: str) -> None:
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index a50f9fdc4f1..15e52f90a04 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -1204,8 +1204,8 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="compressor_inlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getCompressorInletTemperature(),
- unit_getter=lambda api: api.getCompressorInletTemperatureUnit(),
+ value_getter=lambda api: api.getInletTemperature(),
+ unit_getter=lambda api: api.getInletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
@@ -1213,8 +1213,8 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="compressor_outlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getCompressorOutletTemperature(),
- unit_getter=lambda api: api.getCompressorOutletTemperatureUnit(),
+ value_getter=lambda api: api.getOutletTemperature(),
+ unit_getter=lambda api: api.getOutletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
@@ -1222,8 +1222,8 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="compressor_inlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
- value_getter=lambda api: api.getCompressorInletPressure(),
- unit_getter=lambda api: api.getCompressorInletPressureUnit(),
+ value_getter=lambda api: api.getInletPressure(),
+ unit_getter=lambda api: api.getInletPressureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
@@ -1231,8 +1231,8 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="compressor_outlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
- value_getter=lambda api: api.getCompressorOutletPressure(),
- unit_getter=lambda api: api.getCompressorOutletPressureUnit(),
+ value_getter=lambda api: api.getOutletPressure(),
+ unit_getter=lambda api: api.getOutletPressureUnit(),
entity_registry_enabled_default=False,
),
)
@@ -1243,8 +1243,8 @@ CONDENSER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="condenser_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getCondensorLiquidTemperature(),
- unit_getter=lambda api: api.getCondensorLiquidTemperatureUnit(),
+ value_getter=lambda api: api.getLiquidTemperature(),
+ unit_getter=lambda api: api.getLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
@@ -1252,8 +1252,8 @@ CONDENSER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="condenser_subcooling_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getCondensorSubcoolingTemperature(),
- unit_getter=lambda api: api.getCondensorSubcoolingTemperatureUnit(),
+ value_getter=lambda api: api.getSubcoolingTemperature(),
+ unit_getter=lambda api: api.getSubcoolingTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
@@ -1264,8 +1264,8 @@ EVAPORATOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="evaporator_overheat_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getEvaporatorOverheatTemperature(),
- unit_getter=lambda api: api.getEvaporatorOverheatTemperatureUnit(),
+ value_getter=lambda api: api.getOverheatTemperature(),
+ unit_getter=lambda api: api.getOverheatTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
@@ -1273,8 +1273,8 @@ EVAPORATOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
translation_key="evaporator_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- value_getter=lambda api: api.getEvaporatorLiquidTemperature(),
- unit_getter=lambda api: api.getEvaporatorLiquidTemperatureUnit(),
+ value_getter=lambda api: api.getLiquidTemperature(),
+ unit_getter=lambda api: api.getLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py
index f070464800c..6d8bbf7b8ea 100644
--- a/homeassistant/components/vodafone_station/__init__.py
+++ b/homeassistant/components/vodafone_station/__init__.py
@@ -10,7 +10,12 @@ from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
from .utils import async_client_session
-PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR]
+PLATFORMS = [
+ Platform.BUTTON,
+ Platform.DEVICE_TRACKER,
+ Platform.IMAGE,
+ Platform.SENSOR,
+]
async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py
index e03f6ebde54..6a5d8494669 100644
--- a/homeassistant/components/vodafone_station/coordinator.py
+++ b/homeassistant/components/vodafone_station/coordinator.py
@@ -54,6 +54,7 @@ class UpdateCoordinatorDataType:
devices: dict[str, VodafoneStationDeviceInfo]
sensors: dict[str, Any]
+ wifi: dict[str, Any]
class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
@@ -137,6 +138,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
await self.api.login()
raw_data_devices = await self.api.get_devices_data()
data_sensors = await self.api.get_sensor_data()
+ data_wifi = await self.api.get_wifi_data()
await self.api.logout()
except exceptions.CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
@@ -178,7 +180,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.previous_devices = current_devices
- return UpdateCoordinatorDataType(data_devices, data_sensors)
+ return UpdateCoordinatorDataType(data_devices, data_sensors, data_wifi)
@property
def signal_device_new(self) -> str:
diff --git a/homeassistant/components/vodafone_station/image.py b/homeassistant/components/vodafone_station/image.py
new file mode 100644
index 00000000000..f28d7eb9955
--- /dev/null
+++ b/homeassistant/components/vodafone_station/image.py
@@ -0,0 +1,87 @@
+"""Vodafone Station image."""
+
+from __future__ import annotations
+
+from io import BytesIO
+from typing import Final, cast
+
+from aiovodafone.const import WIFI_DATA
+
+from homeassistant.components.image import ImageEntity, ImageEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import _LOGGER
+from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+IMAGE_TYPES: Final = (
+ ImageEntityDescription(
+ key="guest",
+ translation_key="guest",
+ ),
+ ImageEntityDescription(
+ key="guest_5g",
+ translation_key="guest_5g",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: VodafoneConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Guest WiFi QR code for device."""
+ _LOGGER.debug("Setting up Vodafone Station images")
+
+ coordinator = entry.runtime_data
+
+ wifi = coordinator.data.wifi
+
+ async_add_entities(
+ VodafoneGuestWifiQRImage(hass, coordinator, image_desc)
+ for image_desc in IMAGE_TYPES
+ if image_desc.key in wifi[WIFI_DATA]
+ and "qr_code" in wifi[WIFI_DATA][image_desc.key]
+ )
+
+
+class VodafoneGuestWifiQRImage(
+ CoordinatorEntity[VodafoneStationRouter],
+ ImageEntity,
+):
+ """Implementation of the Guest wifi QR code image entity."""
+
+ _attr_content_type = "image/png"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ coordinator: VodafoneStationRouter,
+ description: ImageEntityDescription,
+ ) -> None:
+ """Initialize QR code image entity."""
+ super().__init__(coordinator)
+ ImageEntity.__init__(self, hass)
+
+ self.entity_description = description
+ self._attr_device_info = coordinator.device_info
+ self._attr_unique_id = f"{coordinator.serial_number}-{description.key}-qr-code"
+
+ async def async_image(self) -> bytes | None:
+ """Return QR code image bytes."""
+ qr_code = cast(
+ BytesIO,
+ self.coordinator.data.wifi[WIFI_DATA][self.entity_description.key][
+ "qr_code"
+ ],
+ )
+ return qr_code.getvalue()
diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json
index 0002e01307d..bf07ce79af1 100644
--- a/homeassistant/components/vodafone_station/strings.json
+++ b/homeassistant/components/vodafone_station/strings.json
@@ -65,6 +65,14 @@
"name": "Internet key reconnect"
}
},
+ "image": {
+ "guest": {
+ "name": "Guest network"
+ },
+ "guest_5g": {
+ "name": "Guest 5GHz network"
+ }
+ },
"sensor": {
"active_connection": {
"name": "Active connection",
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index 8c4dd6c267c..4d6756c3419 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["voip_utils"],
"quality_scale": "internal",
- "requirements": ["voip-utils==0.3.4"]
+ "requirements": ["voip-utils==0.3.5"]
}
diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py
index 06e9e0dfdac..c8cc166ec01 100644
--- a/homeassistant/components/w800rf32/binary_sensor.py
+++ b/homeassistant/components/w800rf32/binary_sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from datetime import timedelta
import logging
import voluptuous as vol
@@ -10,6 +11,7 @@ import W800rf32 as w800
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
+ BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICES, CONF_NAME
@@ -30,7 +32,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
vol.Required(CONF_DEVICES): {
cv.string: vol.Schema(
{
- vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_OFF_DELAY): vol.All(
cv.time_period, cv.positive_timedelta
@@ -53,7 +55,8 @@ async def async_setup_platform(
binary_sensors = []
# device_id --> "c1 or a3" X10 device. entity (type dictionary)
# --> name, device_class etc
- for device_id, entity in config[CONF_DEVICES].items():
+ devices_config: dict[str, ConfigType] = config[CONF_DEVICES]
+ for device_id, entity in devices_config.items():
_LOGGER.debug(
"Add %s w800rf32.binary_sensor (class %s)",
entity[CONF_NAME],
@@ -62,7 +65,7 @@ async def async_setup_platform(
device = W800rf32BinarySensor(
device_id,
- entity.get(CONF_NAME),
+ entity[CONF_NAME],
entity.get(CONF_DEVICE_CLASS),
entity.get(CONF_OFF_DELAY),
)
@@ -77,13 +80,21 @@ class W800rf32BinarySensor(BinarySensorEntity):
_attr_should_poll = False
- def __init__(self, device_id, name, device_class=None, off_delay=None):
+ def __init__(
+ self,
+ device_id: str,
+ name: str,
+ device_class: BinarySensorDeviceClass | None,
+ off_delay: timedelta | None,
+ ) -> None:
"""Initialize the w800rf32 sensor."""
self._signal = W800RF32_DEVICE.format(device_id)
- self._name = name
- self._device_class = device_class
+
+ self._attr_name = name
+ self._attr_device_class = device_class
+ self._attr_is_on = False
+
self._off_delay = off_delay
- self._state = False
self._delay_listener = None
@callback
@@ -92,21 +103,6 @@ class W800rf32BinarySensor(BinarySensorEntity):
self._delay_listener = None
self.update_state(False)
- @property
- def name(self):
- """Return the device name."""
- return self._name
-
- @property
- def device_class(self):
- """Return the sensor class."""
- return self._device_class
-
- @property
- def is_on(self):
- """Return true if the sensor state is True."""
- return self._state
-
@callback
def binary_sensor_update(self, event):
"""Call for control updates from the w800rf32 gateway."""
@@ -131,9 +127,9 @@ class W800rf32BinarySensor(BinarySensorEntity):
self.hass, self._off_delay, self._off_delay_listener
)
- def update_state(self, state):
+ def update_state(self, state: bool) -> None:
"""Update the state of the device."""
- self._state = state
+ self._attr_is_on = state
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json
index be94ffd6fc5..15ac301a739 100644
--- a/homeassistant/components/waterfurnace/manifest.json
+++ b/homeassistant/components/waterfurnace/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
"quality_scale": "legacy",
- "requirements": ["waterfurnace==1.2.0"]
+ "requirements": ["waterfurnace==1.4.0"]
}
diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py
index 806e7abed00..8cc4c53a479 100644
--- a/homeassistant/components/wirelesstag/__init__.py
+++ b/homeassistant/components/wirelesstag/__init__.py
@@ -4,7 +4,7 @@ import logging
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
-from wirelesstagpy import WirelessTags
+from wirelesstagpy import SensorTag, WirelessTags
from wirelesstagpy.exceptions import WirelessTagsException
from homeassistant.components import persistent_notification
@@ -14,7 +14,12 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, SIGNAL_TAG_UPDATE
+from .const import (
+ DOMAIN,
+ SIGNAL_BINARY_EVENT_UPDATE,
+ SIGNAL_TAG_UPDATE,
+ WIRELESSTAG_DATA,
+)
_LOGGER = logging.getLogger(__name__)
@@ -39,14 +44,14 @@ CONFIG_SCHEMA = vol.Schema(
class WirelessTagPlatform:
"""Principal object to manage all registered in HA tags."""
- def __init__(self, hass, api):
+ def __init__(self, hass: HomeAssistant, api: WirelessTags) -> None:
"""Designated initializer for wirelesstags platform."""
self.hass = hass
self.api = api
- self.tags = {}
+ self.tags: dict[str, SensorTag] = {}
self._local_base_url = None
- def load_tags(self):
+ def load_tags(self) -> dict[str, SensorTag]:
"""Load tags from remote server."""
self.tags = self.api.load_tags()
return self.tags
@@ -104,9 +109,9 @@ class WirelessTagPlatform:
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Wireless Sensor Tag component."""
- conf = config[DOMAIN]
- username = conf.get(CONF_USERNAME)
- password = conf.get(CONF_PASSWORD)
+ conf: ConfigType = config[DOMAIN]
+ username: str = conf[CONF_USERNAME]
+ password: str = conf[CONF_PASSWORD]
try:
wirelesstags = WirelessTags(username=username, password=password)
@@ -114,7 +119,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
platform = WirelessTagPlatform(hass, wirelesstags)
platform.load_tags()
platform.start_monitoring()
- hass.data[DOMAIN] = platform
+ hass.data[WIRELESSTAG_DATA] = platform
except (ConnectTimeout, HTTPError, WirelessTagsException) as ex:
_LOGGER.error("Unable to connect to wirelesstag.net service: %s", str(ex))
persistent_notification.create(
diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py
index 8a0957e16e3..430c4c07bde 100644
--- a/homeassistant/components/wirelesstag/binary_sensor.py
+++ b/homeassistant/components/wirelesstag/binary_sensor.py
@@ -3,9 +3,11 @@
from __future__ import annotations
import voluptuous as vol
+from wirelesstagpy import SensorTag, constants as WT_CONSTANTS
from homeassistant.components.binary_sensor import (
PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
+ BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform
@@ -15,53 +17,24 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE
+from . import WirelessTagPlatform
+from .const import SIGNAL_BINARY_EVENT_UPDATE, WIRELESSTAG_DATA
from .entity import WirelessTagBaseSensor
from .util import async_migrate_unique_id
-# On means in range, Off means out of range
-SENSOR_PRESENCE = "presence"
-
-# On means motion detected, Off means clear
-SENSOR_MOTION = "motion"
-
-# On means open, Off means closed
-SENSOR_DOOR = "door"
-
-# On means temperature become too cold, Off means normal
-SENSOR_COLD = "cold"
-
-# On means hot, Off means normal
-SENSOR_HEAT = "heat"
-
-# On means too dry (humidity), Off means normal
-SENSOR_DRY = "dry"
-
-# On means too wet (humidity), Off means normal
-SENSOR_WET = "wet"
-
-# On means light detected, Off means no light
-SENSOR_LIGHT = "light"
-
-# On means moisture detected (wet), Off means no moisture (dry)
-SENSOR_MOISTURE = "moisture"
-
-# On means tag battery is low, Off means normal
-SENSOR_BATTERY = "battery"
-
# Sensor types: Name, device_class, push notification type representing 'on',
# attr to check
SENSOR_TYPES = {
- SENSOR_PRESENCE: "Presence",
- SENSOR_MOTION: "Motion",
- SENSOR_DOOR: "Door",
- SENSOR_COLD: "Cold",
- SENSOR_HEAT: "Heat",
- SENSOR_DRY: "Too dry",
- SENSOR_WET: "Too wet",
- SENSOR_LIGHT: "Light",
- SENSOR_MOISTURE: "Leak",
- SENSOR_BATTERY: "Low Battery",
+ WT_CONSTANTS.EVENT_PRESENCE: BinarySensorDeviceClass.PRESENCE,
+ WT_CONSTANTS.EVENT_MOTION: BinarySensorDeviceClass.MOTION,
+ WT_CONSTANTS.EVENT_DOOR: BinarySensorDeviceClass.DOOR,
+ WT_CONSTANTS.EVENT_COLD: BinarySensorDeviceClass.COLD,
+ WT_CONSTANTS.EVENT_HEAT: BinarySensorDeviceClass.HEAT,
+ WT_CONSTANTS.EVENT_DRY: None,
+ WT_CONSTANTS.EVENT_WET: None,
+ WT_CONSTANTS.EVENT_LIGHT: BinarySensorDeviceClass.LIGHT,
+ WT_CONSTANTS.EVENT_MOISTURE: BinarySensorDeviceClass.MOISTURE,
+ WT_CONSTANTS.EVENT_BATTERY: BinarySensorDeviceClass.BATTERY,
}
@@ -81,7 +54,7 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the platform for a WirelessTags."""
- platform = hass.data[DOMAIN]
+ platform = hass.data[WIRELESSTAG_DATA]
sensors = []
tags = platform.tags
@@ -98,11 +71,14 @@ async def async_setup_platform(
class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity):
"""A binary sensor implementation for WirelessTags."""
- def __init__(self, api, tag, sensor_type):
+ def __init__(
+ self, api: WirelessTagPlatform, tag: SensorTag, sensor_type: str
+ ) -> None:
"""Initialize a binary sensor for a Wireless Sensor Tags."""
super().__init__(api, tag)
self._sensor_type = sensor_type
self._name = f"{self._tag.name} {self.event.human_readable_name}"
+ self._attr_device_class = SENSOR_TYPES[sensor_type]
self._attr_unique_id = f"{self._uuid}_{self._sensor_type}"
async def async_added_to_hass(self) -> None:
@@ -123,11 +99,6 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity):
"""Return True if the binary sensor is on."""
return self._state == STATE_ON
- @property
- def device_class(self):
- """Return the class of the binary sensor."""
- return self._sensor_type
-
@property
def event(self):
"""Binary event of tag."""
diff --git a/homeassistant/components/wirelesstag/const.py b/homeassistant/components/wirelesstag/const.py
index c1384606bf1..b9ddf816fb8 100644
--- a/homeassistant/components/wirelesstag/const.py
+++ b/homeassistant/components/wirelesstag/const.py
@@ -1,6 +1,16 @@
"""Support for Wireless Sensor Tags."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from . import WirelessTagPlatform
+
DOMAIN = "wirelesstag"
+WIRELESSTAG_DATA: HassKey[WirelessTagPlatform] = HassKey(DOMAIN)
# Template for signal - first parameter is tag_id,
# second, tag manager mac address
diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py
index 9b92480ecf9..913e1dbf7a0 100644
--- a/homeassistant/components/wirelesstag/sensor.py
+++ b/homeassistant/components/wirelesstag/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import DOMAIN, SIGNAL_TAG_UPDATE
+from .const import DOMAIN, SIGNAL_TAG_UPDATE, WIRELESSTAG_DATA
from .entity import WirelessTagBaseSensor
from .util import async_migrate_unique_id
@@ -78,7 +78,7 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the sensor platform."""
- platform = hass.data[DOMAIN]
+ platform = hass.data[WIRELESSTAG_DATA]
sensors = []
tags = platform.tags
for tag in tags.values():
diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py
index 9fa630d4f55..53e28f9103d 100644
--- a/homeassistant/components/wirelesstag/switch.py
+++ b/homeassistant/components/wirelesstag/switch.py
@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import DOMAIN
+from .const import WIRELESSTAG_DATA
from .entity import WirelessTagBaseSensor
from .util import async_migrate_unique_id
@@ -62,7 +62,7 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up switches for a Wireless Sensor Tags."""
- platform = hass.data[DOMAIN]
+ platform = hass.data[WIRELESSTAG_DATA]
tags = platform.load_tags()
monitored_conditions = config[CONF_MONITORED_CONDITIONS]
diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json
index fdb141dea81..62fc14e9a5f 100644
--- a/homeassistant/components/withings/strings.json
+++ b/homeassistant/components/withings/strings.json
@@ -21,6 +21,9 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"oauth_discovery": {
"description": "Home Assistant has found a Withings device on your network. Be aware that the setup of Withings is more complicated than many other integrations. Press **Submit** to continue setting up Withings."
diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py
index 509d40d654d..8a6de65cf73 100644
--- a/homeassistant/components/wiz/light.py
+++ b/homeassistant/components/wiz/light.py
@@ -80,6 +80,9 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
color_modes.add(RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels])
if features.color_tmp:
color_modes.add(ColorMode.COLOR_TEMP)
+ kelvin = bulb_type.kelvin_range
+ self._attr_max_color_temp_kelvin = kelvin.max
+ self._attr_min_color_temp_kelvin = kelvin.min
if features.brightness:
color_modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
@@ -87,10 +90,6 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
# If the light supports only a single color mode, set it now
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
self._attr_effect_list = wiz_data.scenes
- if bulb_type.bulb_type != BulbClass.DW:
- kelvin = bulb_type.kelvin_range
- self._attr_max_color_temp_kelvin = kelvin.max
- self._attr_min_color_temp_kelvin = kelvin.min
if bulb_type.features.effect:
self._attr_supported_features = LightEntityFeature.EFFECT
self._async_update_attrs()
diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json
index 817441a06c6..4b86d38b76f 100644
--- a/homeassistant/components/wsdot/manifest.json
+++ b/homeassistant/components/wsdot/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["wsdot"],
- "quality_scale": "legacy",
"requirements": ["wsdot==0.0.1"]
}
diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py
index cf088c04d9f..254d9c60429 100644
--- a/homeassistant/components/wyoming/tts.py
+++ b/homeassistant/components/wyoming/tts.py
@@ -47,6 +47,9 @@ async def async_setup_entry(
class WyomingTtsProvider(tts.TextToSpeechEntity):
"""Wyoming text-to-speech provider."""
+ _attr_default_options = {}
+ _attr_supported_options = [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE, ATTR_SPEAKER]
+
def __init__(
self,
config_entry: ConfigEntry,
@@ -78,38 +81,13 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
self._voices[language], key=lambda v: v.name
)
- self._supported_languages: list[str] = list(voice_languages)
+ self._attr_supported_languages = list(voice_languages)
+ if self._attr_supported_languages:
+ self._attr_default_language = self._attr_supported_languages[0]
self._attr_name = self._tts_service.name
self._attr_unique_id = f"{config_entry.entry_id}-tts"
- @property
- def default_language(self):
- """Return default language."""
- if not self._supported_languages:
- return None
-
- return self._supported_languages[0]
-
- @property
- def supported_languages(self):
- """Return list of supported languages."""
- return self._supported_languages
-
- @property
- def supported_options(self):
- """Return list of supported options like voice, emotion."""
- return [
- tts.ATTR_AUDIO_OUTPUT,
- tts.ATTR_VOICE,
- ATTR_SPEAKER,
- ]
-
- @property
- def default_options(self):
- """Return a dict include default options."""
- return {}
-
@callback
def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None:
"""Return a list of supported voices for a language."""
diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py
index 36cb73aff54..66379303bc7 100644
--- a/homeassistant/components/xbox/media_player.py
+++ b/homeassistant/components/xbox/media_player.py
@@ -157,6 +157,8 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
await self.client.smartglass.mute(self._console.id)
else:
await self.client.smartglass.unmute(self._console.id)
+ self._attr_is_volume_muted = mute
+ self.async_write_ha_state()
async def async_volume_up(self) -> None:
"""Turn volume up for media player."""
diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json
index 193b7bdfa53..acd9021b069 100644
--- a/homeassistant/components/xbox/strings.json
+++ b/homeassistant/components/xbox/strings.json
@@ -15,6 +15,9 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"oauth_discovery": {
"description": "Home Assistant has found an Xbox device on your network. Press **Submit** to continue setting up the Xbox integration.",
diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json
index 513c2c72994..c81374741a2 100644
--- a/homeassistant/components/xiaomi_ble/manifest.json
+++ b/homeassistant/components/xiaomi_ble/manifest.json
@@ -25,5 +25,5 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["xiaomi-ble==1.4.1"]
+ "requirements": ["xiaomi-ble==1.5.0"]
}
diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py
index d44a826e50c..964f66f1db2 100644
--- a/homeassistant/components/xmpp/notify.py
+++ b/homeassistant/components/xmpp/notify.py
@@ -9,6 +9,7 @@ import mimetypes
import pathlib
import random
import string
+from typing import Any
import requests
import slixmpp
@@ -101,7 +102,7 @@ class XmppNotificationService(BaseNotificationService):
self._verify = verify
self._room = room
- async def async_send_message(self, message="", **kwargs):
+ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
text = f"{title}: {message}" if title else message
diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py
index cf7cd2a16d9..d6f0c620c7f 100644
--- a/homeassistant/components/yolink/sensor.py
+++ b/homeassistant/components/yolink/sensor.py
@@ -212,6 +212,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
exists_fn=lambda device: device.device_type in BATTERY_POWER_SENSOR,
should_update_entity=lambda value: value is not None,
@@ -251,9 +252,11 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
# mcu temperature
YoLinkSensorEntityDescription(
key="devTemperature",
+ translation_key="device_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
exists_fn=lambda device: device.device_type in MCU_DEV_TEMPERATURE_SENSOR,
should_update_entity=lambda value: value is not None,
value=lambda device, data: data.get("devTemperature"),
diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json
index ee6d6c23c41..449a5a7028b 100644
--- a/homeassistant/components/yolink/strings.json
+++ b/homeassistant/components/yolink/strings.json
@@ -67,6 +67,9 @@
"current_power": {
"name": "Current power"
},
+ "device_temperature": {
+ "name": "Device temperature"
+ },
"power_consumption": {
"name": "Power consumption"
},
diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json
index 2672f249de8..18d90974cd3 100644
--- a/homeassistant/components/youtube/strings.json
+++ b/homeassistant/components/youtube/strings.json
@@ -17,6 +17,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
+ "initiate_flow": {
+ "user": "[%key:common::config_flow::initiate_flow::account%]"
+ },
"step": {
"channels": {
"data": { "channels": "YouTube channels" },
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index 2076c37856e..e14fd0757f6 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -840,19 +840,26 @@ class NodeEvents:
# After ensuring the node is set up in HA, we should check if the node's
# device config has changed, and if so, issue a repair registry entry for a
# possible reinterview
- if not node.is_controller_node and await node.async_has_device_config_changed():
- device_name = device.name_by_user or device.name or "Unnamed device"
- async_create_issue(
- self.hass,
- DOMAIN,
- f"device_config_file_changed.{device.id}",
- data={"device_id": device.id, "device_name": device_name},
- is_fixable=True,
- is_persistent=False,
- translation_key="device_config_file_changed",
- translation_placeholders={"device_name": device_name},
- severity=IssueSeverity.WARNING,
- )
+ if not node.is_controller_node:
+ issue_id = f"device_config_file_changed.{device.id}"
+ if await node.async_has_device_config_changed():
+ device_name = device.name_by_user or device.name or "Unnamed device"
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ issue_id,
+ data={"device_id": device.id, "device_name": device_name},
+ is_fixable=True,
+ is_persistent=False,
+ translation_key="device_config_file_changed",
+ translation_placeholders={"device_name": device_name},
+ severity=IssueSeverity.WARNING,
+ )
+ else:
+ # Clear any existing repair issue if the device config is not considered
+ # changed. This can happen when the original issue was created by
+ # an upstream bug, or the change has been reverted.
+ async_delete_issue(self.hass, DOMAIN, issue_id)
async def async_handle_discovery_info(
self,
diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json
index 1b28da3ab9d..cdef87d987a 100644
--- a/homeassistant/components/zwave_js/manifest.json
+++ b/homeassistant/components/zwave_js/manifest.json
@@ -9,7 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
- "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.1"],
+ "requirements": ["pyserial==3.5", "zwave-js-server-python==0.68.0"],
"usb": [
{
"known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"],
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index beb86c1fd46..5df715b03ca 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -5,15 +5,14 @@ from __future__ import annotations
import abc
import asyncio
from collections import defaultdict
-from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping
+from collections.abc import Callable, Container, Hashable, Iterable, Mapping
from contextlib import suppress
import copy
from dataclasses import dataclass
from enum import StrEnum
-import functools
import logging
from types import MappingProxyType
-from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast
+from typing import Any, Generic, Required, TypedDict, TypeVar, cast
import voluptuous as vol
@@ -151,15 +150,6 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False):
url: str
-class ProgressStepData[_FlowResultT](TypedDict):
- """Typed data for progress step tracking."""
-
- tasks: dict[str, asyncio.Task[Any]]
- abort_reason: str
- abort_description_placeholders: Mapping[str, str]
- next_step_result: _FlowResultT | None
-
-
def _map_error_to_schema_errors(
schema_errors: dict[str, Any],
error: vol.Invalid,
@@ -645,24 +635,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
__progress_task: asyncio.Task[Any] | None = None
__no_progress_task_reported = False
deprecated_show_progress = False
- __progress_step_data: ProgressStepData[_FlowResultT] | None = None
-
- @property
- def _progress_step_data(self) -> ProgressStepData[_FlowResultT]:
- """Return progress step data.
-
- A property is used instead of a simple attribute as derived classes
- do not call super().__init__.
- The property makes sure that the dict is initialized if needed.
- """
- if not self.__progress_step_data:
- self.__progress_step_data = {
- "tasks": {},
- "abort_reason": "",
- "abort_description_placeholders": MappingProxyType({}),
- "next_step_result": None,
- }
- return self.__progress_step_data
@property
def source(self) -> str | None:
@@ -785,39 +757,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
description_placeholders=description_placeholders,
)
- async def async_step__progress_step_abort(
- self, user_input: dict[str, Any] | None = None
- ) -> _FlowResultT:
- """Abort the flow."""
- progress_step_data = self._progress_step_data
- return self.async_abort(
- reason=progress_step_data["abort_reason"],
- description_placeholders=progress_step_data[
- "abort_description_placeholders"
- ],
- )
-
- async def async_step__progress_step_progress_done(
- self, user_input: dict[str, Any] | None = None
- ) -> _FlowResultT:
- """Progress done. Return the next step.
-
- Used by the progress_step decorator
- to allow decorated step methods
- to call the next step method, to change step,
- without using async_show_progress_done.
- If no next step is set, abort the flow.
- """
- progress_step_data = self._progress_step_data
- if (next_step_result := progress_step_data["next_step_result"]) is None:
- return self.async_abort(
- reason=progress_step_data["abort_reason"],
- description_placeholders=progress_step_data[
- "abort_description_placeholders"
- ],
- )
- return next_step_result
-
@callback
def async_external_step(
self,
@@ -998,90 +937,3 @@ class section:
def __call__(self, value: Any) -> Any:
"""Validate input."""
return self.schema(value)
-
-
-type _FuncType[_T: FlowHandler[Any, Any, Any], _R: FlowResult[Any, Any], **_P] = (
- Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
-)
-
-
-def progress_step[
- HandlerT: FlowHandler[Any, Any, Any],
- ResultT: FlowResult[Any, Any],
- **P,
-](
- description_placeholders: (
- dict[str, str] | Callable[[Any], dict[str, str]] | None
- ) = None,
-) -> Callable[[_FuncType[HandlerT, ResultT, P]], _FuncType[HandlerT, ResultT, P]]:
- """Decorator to create a progress step from an async function.
-
- The decorated method should be a step method
- which needs to show progress.
- The method should accept dict[str, Any] as user_input
- and should return a FlowResult or raise AbortFlow.
- The method can call self.async_update_progress(progress)
- to update progress.
-
- Args:
- description_placeholders: Static dict or callable that returns dict for progress UI placeholders.
- """
-
- def decorator(
- func: _FuncType[HandlerT, ResultT, P],
- ) -> _FuncType[HandlerT, ResultT, P]:
- @functools.wraps(func)
- async def wrapper(
- self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs
- ) -> ResultT:
- step_id = func.__name__.replace("async_step_", "")
- progress_step_data = self._progress_step_data
- # Check if we have a progress task running
- progress_task = progress_step_data["tasks"].get(step_id)
-
- if progress_task is None:
- # First call - create and start the progress task
- progress_task = self.hass.async_create_task(
- func(self, *args, **kwargs), # type: ignore[arg-type]
- f"Progress step {step_id}",
- )
- progress_step_data["tasks"][step_id] = progress_task
-
- if not progress_task.done():
- # Handle description placeholders
- placeholders = None
- if description_placeholders is not None:
- if callable(description_placeholders):
- placeholders = description_placeholders(self)
- else:
- placeholders = description_placeholders
-
- return self.async_show_progress(
- step_id=step_id,
- progress_action=step_id,
- progress_task=progress_task,
- description_placeholders=placeholders,
- )
-
- # Task is done or this is a subsequent call
- try:
- progress_step_data["next_step_result"] = await progress_task
- except AbortFlow as err:
- progress_step_data["abort_reason"] = err.reason
- progress_step_data["abort_description_placeholders"] = (
- err.description_placeholders or {}
- )
- return self.async_show_progress_done(
- next_step_id="_progress_step_abort"
- )
- finally:
- # Clean up task reference
- progress_step_data["tasks"].pop(step_id, None)
-
- return self.async_show_progress_done(
- next_step_id="_progress_step_progress_done"
- )
-
- return wrapper
-
- return decorator
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 2f0829b0756..83f00f52d54 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -443,6 +443,7 @@ FLOWS = {
"mystrom",
"myuplink",
"nam",
+ "namecheapdns",
"nanoleaf",
"nasweb",
"neato",
@@ -536,6 +537,7 @@ FLOWS = {
"prosegur",
"prowl",
"proximity",
+ "proxmoxve",
"prusalink",
"ps4",
"pterodactyl",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 9ad1ad0e827..5319c300eea 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -4343,8 +4343,8 @@
},
"namecheapdns": {
"name": "Namecheap DynamicDNS",
- "integration_type": "hub",
- "config_flow": false,
+ "integration_type": "service",
+ "config_flow": true,
"iot_class": "cloud_push"
},
"nanoleaf": {
@@ -5245,8 +5245,8 @@
},
"proxmoxve": {
"name": "Proxmox VE",
- "integration_type": "hub",
- "config_flow": false,
+ "integration_type": "service",
+ "config_flow": true,
"iot_class": "local_polling"
},
"proxy": {
@@ -5375,7 +5375,7 @@
"name": "QNAP"
},
"qnap_qsw": {
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "QNAP QSW"
@@ -5413,7 +5413,7 @@
},
"rabbitair": {
"name": "Rabbit Air",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5438,7 +5438,7 @@
},
"radiotherm": {
"name": "Radio Thermostat",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5473,7 +5473,7 @@
},
"rapt_ble": {
"name": "RAPT Bluetooth",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -5571,7 +5571,7 @@
},
"renson": {
"name": "Renson",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -5679,13 +5679,13 @@
},
"romy": {
"name": "ROMY Vacuum Cleaner",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"roomba": {
"name": "iRobot Roomba and Braava",
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -5720,7 +5720,7 @@
},
"rova": {
"name": "ROVA",
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -5763,13 +5763,13 @@
"name": "Ruuvi",
"integrations": {
"ruuvi_gateway": {
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "Ruuvi Gateway"
},
"ruuvitag_ble": {
- "integration_type": "hub",
+ "integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Ruuvi BLE"
@@ -5784,7 +5784,7 @@
},
"sabnzbd": {
"name": "SABnzbd",
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": true,
"iot_class": "local_polling"
},
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 957ff25434f..2c82756c0a4 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -13,7 +13,18 @@ import inspect
import logging
import re
import sys
-from typing import TYPE_CHECKING, Any, Protocol, TypedDict, Unpack, cast, overload
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Final,
+ Literal,
+ Protocol,
+ TypedDict,
+ Unpack,
+ cast,
+ overload,
+ override,
+)
import voluptuous as vol
@@ -43,7 +54,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
WEEKDAYS,
)
-from homeassistant.core import HomeAssistant, State, callback
+from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.exceptions import (
ConditionError,
ConditionErrorContainer,
@@ -71,6 +82,7 @@ from .automation import (
)
from .integration_platform import async_process_integration_platforms
from .selector import TargetSelector
+from .target import TargetSelection, async_extract_referenced_entity_ids
from .template import Template, render_complex
from .trace import (
TraceElement,
@@ -302,6 +314,112 @@ class Condition(abc.ABC):
"""Get the condition checker."""
+ATTR_BEHAVIOR: Final = "behavior"
+BEHAVIOR_ANY: Final = "any"
+BEHAVIOR_ALL: Final = "all"
+
+STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
+ vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
+ [BEHAVIOR_ANY, BEHAVIOR_ALL]
+ ),
+}
+ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema(
+ {
+ vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
+ vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
+ }
+)
+
+
+class EntityStateConditionBase(Condition):
+ """State condition."""
+
+ _domain: str
+ _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
+ _states: set[str]
+
+ @override
+ @classmethod
+ async def async_validate_config(
+ cls, hass: HomeAssistant, config: ConfigType
+ ) -> ConfigType:
+ """Validate config."""
+ return cast(ConfigType, cls._schema(config))
+
+ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
+ """Initialize condition."""
+ super().__init__(hass, config)
+ if TYPE_CHECKING:
+ assert config.target
+ assert config.options
+ self._target_selection = TargetSelection(config.target)
+ self._behavior = config.options[ATTR_BEHAVIOR]
+
+ def entity_filter(self, entities: set[str]) -> set[str]:
+ """Filter entities of this domain."""
+ return {
+ entity_id
+ for entity_id in entities
+ if split_entity_id(entity_id)[0] == self._domain
+ }
+
+ @override
+ async def async_get_checker(self) -> ConditionChecker:
+ """Get the condition checker."""
+
+ def check_any_match_state(states: list[str]) -> bool:
+ """Test if any entity match the state."""
+ return any(state in self._states for state in states)
+
+ def check_all_match_state(states: list[str]) -> bool:
+ """Test if all entities match the state."""
+ return all(state in self._states for state in states)
+
+ matcher: Callable[[list[str]], bool]
+ if self._behavior == BEHAVIOR_ANY:
+ matcher = check_any_match_state
+ elif self._behavior == BEHAVIOR_ALL:
+ matcher = check_all_match_state
+
+ def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool:
+ """Test state condition."""
+ targeted_entities = async_extract_referenced_entity_ids(
+ self._hass, self._target_selection, expand_group=False
+ )
+ referenced_entity_ids = targeted_entities.referenced.union(
+ targeted_entities.indirectly_referenced
+ )
+ filtered_entity_ids = self.entity_filter(referenced_entity_ids)
+ entity_states = [
+ _state.state
+ for entity_id in filtered_entity_ids
+ if (_state := self._hass.states.get(entity_id))
+ and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
+ ]
+ return matcher(entity_states)
+
+ return test_state
+
+
+def make_entity_state_condition(
+ domain: str, states: str | set[str]
+) -> type[EntityStateConditionBase]:
+ """Create a condition for entity state changes to specific state(s)."""
+
+ if isinstance(states, str):
+ states_set = {states}
+ else:
+ states_set = states
+
+ class CustomCondition(EntityStateConditionBase):
+ """Condition for entity state."""
+
+ _domain = domain
+ _states = states_set
+
+ return CustomCondition
+
+
class ConditionProtocol(Protocol):
"""Define the format of condition modules."""
@@ -1229,13 +1347,18 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]:
if entity_ids is not None:
referenced.update(entity_ids)
+ if target_entities := _get_targets_from_condition_config(
+ config, CONF_ENTITY_ID
+ ):
+ referenced.update(target_entities)
+
return referenced
@callback
def async_extract_devices(config: ConfigType | Template) -> set[str]:
"""Extract devices from a condition."""
- referenced = set()
+ referenced: set[str] = set()
to_process = deque([config])
while to_process:
@@ -1249,15 +1372,57 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]:
to_process.extend(config["conditions"])
continue
- if condition != "device":
+ if condition == "device":
+ if (device_id := config.get(CONF_DEVICE_ID)) is not None:
+ referenced.add(device_id)
continue
- if (device_id := config.get(CONF_DEVICE_ID)) is not None:
- referenced.add(device_id)
+ if target_devices := _get_targets_from_condition_config(config, CONF_DEVICE_ID):
+ referenced.update(target_devices)
return referenced
+@callback
+def async_extract_targets(
+ config: ConfigType | Template,
+ target_type: Literal["area_id", "floor_id", "label_id"],
+) -> set[str]:
+ """Extract targets from a condition."""
+ referenced: set[str] = set()
+ to_process = deque([config])
+
+ while to_process:
+ config = to_process.popleft()
+ if isinstance(config, Template):
+ continue
+
+ condition = config[CONF_CONDITION]
+
+ if condition in ("and", "not", "or"):
+ to_process.extend(config["conditions"])
+ continue
+
+ if targets := _get_targets_from_condition_config(config, target_type):
+ referenced.update(targets)
+
+ return referenced
+
+
+@callback
+def _get_targets_from_condition_config(
+ config: ConfigType,
+ target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
+) -> list[str]:
+ """Extract targets from a condition target config."""
+ if not (target_conf := config.get(CONF_TARGET)):
+ return []
+ if not (targets := target_conf.get(target)):
+ return []
+
+ return [targets] if isinstance(targets, str) else targets
+
+
def _load_conditions_file(integration: Integration) -> dict[str, Any]:
"""Load conditions file for an integration."""
try:
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 9a52a8edace..94aa8b626d8 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -452,6 +452,11 @@ class Entity(
# Entity description instance for this Entity
entity_description: EntityDescription
+ # Integration suggested object id, derived from entity_id, if it is set by the
+ # integration before the entity is added.
+ # Only handled internally, never to be used by integrations.
+ internal_integration_suggested_object_id: str | None
+
# If we reported if this entity was slow
_slow_reported = False
@@ -715,7 +720,7 @@ class Entity(
@property
def suggested_object_id(self) -> str | None:
- """Return input for object id."""
+ """Return suggested object id."""
if (
# Check our class has overridden the name property from Entity
# We need to use type.__getattribute__ to retrieve the underlying
@@ -733,6 +738,7 @@ class Entity(
)
else:
name = self.name
+
return None if name is UNDEFINED else name
@cached_property
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index 7fbeaad28f4..cf13e27c2cf 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -29,8 +29,8 @@ from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.setup import async_prepare_setup_platform
from homeassistant.util.hass_dict import HassKey
-from . import device_registry as dr, discovery, entity, entity_registry as er, service
-from .entity_platform import EntityPlatform, async_calculate_suggested_object_id
+from . import discovery, entity, service
+from .entity_platform import EntityPlatform
from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType
DEFAULT_SCAN_INTERVAL = timedelta(seconds=15)
@@ -58,36 +58,6 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None:
await entity_obj.async_update_ha_state(True)
-@callback
-def async_get_entity_suggested_object_id(
- hass: HomeAssistant, entity_id: str
-) -> str | None:
- """Get the suggested object id for an entity.
-
- Raises HomeAssistantError if the entity is not in the registry or
- is not backed by an object.
- """
- entity_registry = er.async_get(hass)
- if not (entity_entry := entity_registry.async_get(entity_id)):
- raise HomeAssistantError(f"Entity {entity_id} is not in the registry.")
-
- domain = entity_id.partition(".")[0]
-
- if entity_entry.name:
- return entity_entry.name
-
- if entity_entry.suggested_object_id:
- return entity_entry.suggested_object_id
-
- entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain)
- if not (entity_obj := entity_comp.get_entity(entity_id) if entity_comp else None):
- raise HomeAssistantError(f"Entity {entity_id} has no object.")
- device: dr.DeviceEntry | None = None
- if device_id := entity_entry.device_id:
- device = dr.async_get(hass).async_get(device_id)
- return async_calculate_suggested_object_id(entity_obj, device)
-
-
class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
"""The EntityComponent manages platforms that manage entities.
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 514f0dcae5d..a57345023c9 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
from contextvars import ContextVar
from datetime import timedelta
from logging import Logger, getLogger
-from typing import TYPE_CHECKING, Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol, overload
from homeassistant import config_entries
from homeassistant.const import (
@@ -38,12 +38,7 @@ from homeassistant.setup import SetupPhases, async_start_setup
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
-from . import (
- device_registry as dev_reg,
- entity_registry as ent_reg,
- service,
- translation,
-)
+from . import device_registry as dr, entity_registry as er, service, translation
from .deprecation import deprecated_function
from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider
from .event import async_call_later
@@ -624,7 +619,7 @@ class EntityPlatform:
event loop and will finish faster if we run them concurrently.
"""
results: list[BaseException | None] | None = None
- entity_registry = ent_reg.async_get(self.hass)
+ entity_registry = er.async_get(self.hass)
try:
async with self.hass.timeout.async_timeout(timeout, self.domain):
results = await asyncio.gather(
@@ -676,7 +671,7 @@ class EntityPlatform:
to the event loop so we can await the coros directly without
scheduling them as tasks.
"""
- entity_registry = ent_reg.async_get(self.hass)
+ entity_registry = er.async_get(self.hass)
try:
async with self.hass.timeout.async_timeout(timeout, self.domain):
for entity in entities:
@@ -791,7 +786,7 @@ class EntityPlatform:
already_exists = True
return (already_exists, restored)
- async def _async_add_entity( # noqa: C901
+ async def _async_add_entity(
self,
entity: Entity,
update_before_add: bool,
@@ -818,12 +813,23 @@ class EntityPlatform:
entity.add_to_platform_abort()
return
- suggested_object_id: str | None = None
-
entity_name = entity.name
if entity_name is UNDEFINED:
entity_name = None
+ suggested_object_id: str | None = None
+
+ # An entity may suggest the entity_id by setting entity_id itself
+ if not hasattr(entity, "internal_integration_suggested_object_id"):
+ if entity.entity_id is not None and not valid_entity_id(entity.entity_id):
+ entity.add_to_platform_abort()
+ raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
+ entity.internal_integration_suggested_object_id = (
+ split_entity_id(entity.entity_id)[1]
+ if entity.entity_id is not None
+ else None
+ )
+
# Get entity_id from unique ID registration
if entity.unique_id is not None:
registered_entity_id = entity_registry.async_get_entity_id(
@@ -852,16 +858,16 @@ class EntityPlatform:
entity.add_to_platform_abort()
return
- device: dev_reg.DeviceEntry | None
+ device: dr.DeviceEntry | None
if self.config_entry:
if device_info := entity.device_info:
try:
- device = dev_reg.async_get(self.hass).async_get_or_create(
+ device = dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**device_info,
)
- except dev_reg.DeviceInfoError as exc:
+ except dr.DeviceInfoError as exc:
self.logger.error(
"%s: Not adding entity with invalid device info: %s",
self.platform_name,
@@ -869,28 +875,14 @@ class EntityPlatform:
)
entity.add_to_platform_abort()
return
+
+ entity.device_entry = device
else:
device = entity.device_entry
else:
device = None
- calculated_object_id: str | None = None
- # An entity may suggest the entity_id by setting entity_id itself
- suggested_entity_id: str | None = entity.entity_id
- if suggested_entity_id is not None:
- suggested_object_id = split_entity_id(entity.entity_id)[1]
- if self.entity_namespace is not None:
- suggested_object_id = (
- f"{self.entity_namespace} {suggested_object_id}"
- )
- if not registered_entity_id and suggested_entity_id is None:
- # Do not bother working out a suggested_object_id
- # if the entity is already registered as it will
- # be ignored.
- #
- calculated_object_id = async_calculate_suggested_object_id(
- entity, device
- )
+ suggested_object_id, object_id_base = _async_derive_object_ids(entity, self)
disabled_by: RegistryEntryDisabler | None = None
if not entity.entity_registry_enabled_default:
@@ -904,7 +896,6 @@ class EntityPlatform:
self.domain,
self.platform_name,
entity.unique_id,
- calculated_object_id=calculated_object_id,
capabilities=entity.capability_attributes,
config_entry=self.config_entry,
config_subentry_id=config_subentry_id,
@@ -914,6 +905,7 @@ class EntityPlatform:
get_initial_options=entity.get_initial_entity_options,
has_entity_name=entity.has_entity_name,
hidden_by=hidden_by,
+ object_id_base=object_id_base,
original_device_class=entity.device_class,
original_icon=entity.icon,
original_name=entity_name,
@@ -929,44 +921,24 @@ class EntityPlatform:
)
entity.registry_entry = entry
- if device:
- entity.device_entry = device
entity.entity_id = entry.entity_id
- else: # entity.unique_id is None
- generate_new_entity_id = False
+ else: # entity.unique_id is None # noqa: PLR5501
# We won't generate an entity ID if the platform has already set one
# We will however make sure that platform cannot pick a registered ID
- if entity.entity_id is not None and entity_registry.async_is_registered(
+ if entity.entity_id is None or entity_registry.async_is_registered(
entity.entity_id
):
- # If entity already registered, convert entity id to suggestion
- suggested_object_id = split_entity_id(entity.entity_id)[1]
- generate_new_entity_id = True
-
- # Generate entity ID
- if entity.entity_id is None or generate_new_entity_id:
- suggested_object_id = (
- suggested_object_id
- or entity.suggested_object_id
- or DEVICE_DEFAULT_NAME
+ object_ids = _async_derive_object_ids(
+ entity, self, fallback_object_id=DEVICE_DEFAULT_NAME
)
-
- if self.entity_namespace is not None:
- suggested_object_id = (
- f"{self.entity_namespace} {suggested_object_id}"
- )
- entity.entity_id = entity_registry.async_generate_entity_id(
+ suggested_object_id = (
+ object_ids[0] if object_ids[0] is not None else object_ids[1]
+ )
+ entity.entity_id = entity_registry.async_get_available_entity_id(
self.domain, suggested_object_id
)
- # Make sure it is valid in case an entity set the value themselves
- # Avoid calling valid_entity_id if we already know it is valid
- # since it already made it in the registry
- if not valid_entity_id(entity.entity_id):
- entity.add_to_platform_abort()
- raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
-
already_exists, restored = self._entity_id_already_exists(entity.entity_id)
if already_exists:
@@ -1234,25 +1206,52 @@ class EntityPlatform:
return await self.platform_data.async_load_translations()
+@overload
+def _async_derive_object_ids(
+ entity: Entity, platform: EntityPlatform, *, fallback_object_id: None = None
+) -> tuple[str | None, str | None]: ...
+
+
+@overload
+def _async_derive_object_ids(
+ entity: Entity, platform: EntityPlatform, *, fallback_object_id: str
+) -> tuple[str, None] | tuple[None, str]: ...
+
+
@callback
-def async_calculate_suggested_object_id(
- entity: Entity, device: dev_reg.DeviceEntry | None
-) -> str | None:
- """Calculate the suggested object ID for an entity."""
- calculated_object_id: str | None = None
- if device and entity.has_entity_name:
- device_name = device.name_by_user or device.name
- if entity.use_device_name:
- calculated_object_id = device_name
- else:
- calculated_object_id = f"{device_name} {entity.suggested_object_id}"
- if not calculated_object_id:
- calculated_object_id = entity.suggested_object_id
+def _async_derive_object_ids(
+ entity: Entity, platform: EntityPlatform, *, fallback_object_id: str | None = None
+) -> tuple[str | None, str | None]:
+ """Derive the object IDs for an entity.
- if (platform := entity.platform) and platform.entity_namespace is not None:
- calculated_object_id = f"{platform.entity_namespace} {calculated_object_id}"
+ Derives both suggested and base object IDs.
+ """
+ is_base = True
+ object_id: str | None
- return calculated_object_id
+ if entity.internal_integration_suggested_object_id is not None:
+ is_base = False
+ object_id = entity.internal_integration_suggested_object_id
+ else:
+ object_id = entity.suggested_object_id
+
+ if not object_id and fallback_object_id is not None:
+ object_id = fallback_object_id
+
+ if platform.entity_namespace is not None:
+ is_base = False
+ if entity.unique_id is not None and not object_id:
+ object_id = f"{platform.platform_name}_{entity.unique_id}"
+ object_id = f"{platform.entity_namespace} {object_id}"
+
+ suggested_object_id: str | None = None
+ object_id_base: str | None = None
+ if is_base:
+ object_id_base = object_id
+ else:
+ suggested_object_id = object_id
+
+ return suggested_object_id, object_id_base
current_platform: ContextVar[EntityPlatform | None] = ContextVar(
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index a6b26af4e02..b04e0109670 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -58,6 +58,7 @@ from .device_registry import (
EVENT_DEVICE_REGISTRY_UPDATED,
EventDeviceRegistryUpdatedData,
)
+from .frame import ReportBehavior, report_usage
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
from .singleton import singleton
@@ -79,7 +80,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 19
+STORAGE_VERSION_MINOR = 20
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -204,6 +205,7 @@ class RegistryEntry:
labels: set[str] = attr.ib(factory=set)
modified_at: datetime = attr.ib(factory=utcnow)
name: str | None = attr.ib(default=None)
+ object_id_base: str | None = attr.ib()
options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options)
# As set by integration
original_device_class: str | None = attr.ib()
@@ -366,6 +368,7 @@ class RegistryEntry:
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.name,
+ "object_id_base": self.object_id_base,
"options": self.options,
"original_device_class": self.original_device_class,
"original_icon": self.original_icon,
@@ -410,6 +413,31 @@ class RegistryEntry:
hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs)
+@callback
+def _async_get_full_entity_name(
+ name: str | None,
+ *,
+ device: dr.DeviceEntry | None = None,
+ platform: str,
+ unique_id: str,
+) -> str:
+ """Get full name for an entity.
+
+ This includes the device name if appropriate.
+ """
+ if device is not None:
+ device_name = device.name_by_user or device.name
+ if not name:
+ name = device_name
+ else:
+ name = f"{device_name} {name}"
+
+ if not name:
+ name = f"{platform}_{unique_id}"
+
+ return name
+
+
@attr.s(frozen=True, slots=True)
class DeletedRegistryEntry:
"""Deleted Entity Registry Entry."""
@@ -619,6 +647,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
entity["hidden_by_undefined"] = set_to_undefined
entity["options_undefined"] = set_to_undefined
+ if old_minor_version < 20:
+ # Version 1.20 adds object_id_base to entities
+ for entity in data["entities"]:
+ entity["object_id_base"] = entity["original_name"]
+
if old_major_version > 1:
raise NotImplementedError
return data
@@ -882,9 +915,41 @@ class EntityRegistry(BaseRegistry):
current_entity_id: str | None = None,
reserved_entity_ids: set[str] | None = None,
) -> str:
- """Generate an entity ID that does not conflict.
+ """Get available entity ID.
- Conflicts checked against registered and currently existing entities.
+ This function is deprecated. Use `async_get_available_entity_id` instead.
+
+ Entity ID conflicts are checked against registered and currently existing entities,
+ as well as provided `reserved_entity_ids`.
+ """
+ report_usage(
+ "calls `entity_registry.async_generate_entity_id`, "
+ "which is deprecated and will be removed in Home Assistant 2027.2; "
+ "use `entity_registry.async_get_available_entity_id` instead",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2027.2.0",
+ )
+
+ return self.async_get_available_entity_id(
+ domain,
+ suggested_object_id,
+ current_entity_id=current_entity_id,
+ reserved_entity_ids=reserved_entity_ids,
+ )
+
+ @callback
+ def async_get_available_entity_id(
+ self,
+ domain: str,
+ suggested_object_id: str,
+ *,
+ current_entity_id: str | None = None,
+ reserved_entity_ids: set[str] | None = None,
+ ) -> str:
+ """Get next available entity ID.
+
+ Entity ID conflicts are checked against registered and currently existing entities,
+ as well as provided `reserved_entity_ids`.
"""
preferred_string = f"{domain}.{slugify(suggested_object_id)}"
@@ -906,6 +971,86 @@ class EntityRegistry(BaseRegistry):
return test_string
+ def _async_generate_entity_id(
+ self,
+ *,
+ current_entity_id: str | None,
+ device_id: str | None,
+ domain: str,
+ has_entity_name: bool,
+ name: str | None,
+ object_id_base: str | None,
+ platform: str,
+ reserved_entity_ids: set[str] | None = None,
+ suggested_object_id: str | None,
+ unique_id: str,
+ ) -> str:
+ """Generate an entity ID, based on all the provided parameters.
+
+ `name` is the name set by the user, not the original name from the integration.
+ `name` has priority over `suggested_object_id`, which has priority
+ over `object_id_base`.
+ `name` and `suggested_object_id` will never be prefixed with the device name,
+ `object_id_base` will be if `has_entity_name` is True.
+
+ Entity ID conflicts are checked against registered and currently
+ existing entities, as well as provided `reserved_entity_ids`.
+ """
+ object_id: str | None
+ use_device = False
+ if name is not None:
+ object_id = name
+ elif suggested_object_id is not None:
+ object_id = suggested_object_id
+ else:
+ object_id = object_id_base
+ if has_entity_name:
+ use_device = True
+
+ device = (
+ dr.async_get(self.hass).async_get(device_id)
+ if use_device and device_id is not None
+ else None
+ )
+
+ object_id = _async_get_full_entity_name(
+ object_id,
+ device=device,
+ platform=platform,
+ unique_id=unique_id,
+ )
+ return self.async_get_available_entity_id(
+ domain,
+ object_id,
+ current_entity_id=current_entity_id,
+ reserved_entity_ids=reserved_entity_ids,
+ )
+
+ @callback
+ def async_regenerate_entity_id(
+ self,
+ entry: RegistryEntry,
+ *,
+ reserved_entity_ids: set[str] | None = None,
+ ) -> str:
+ """Regenerate an entity ID for an entry.
+
+ Entity ID conflicts are checked against registered and currently existing entities,
+ as well as provided `reserved_entity_ids`.
+ """
+ return self._async_generate_entity_id(
+ current_entity_id=entry.entity_id,
+ device_id=entry.device_id,
+ domain=entry.domain,
+ has_entity_name=entry.has_entity_name,
+ name=entry.name,
+ object_id_base=entry.object_id_base,
+ platform=entry.platform,
+ reserved_entity_ids=reserved_entity_ids,
+ suggested_object_id=entry.suggested_object_id,
+ unique_id=entry.unique_id,
+ )
+
@callback
def async_get_or_create(
self,
@@ -913,9 +1058,10 @@ class EntityRegistry(BaseRegistry):
platform: str,
unique_id: str,
*,
- # To influence entity ID generation
- calculated_object_id: str | None = None,
- suggested_object_id: str | None = None,
+ # Used for entity ID generation, if entity gets created.
+ # `suggested_object_id` has priority over `object_id_base`.
+ object_id_base: str | None | UndefinedType = UNDEFINED,
+ suggested_object_id: str | None | UndefinedType = UNDEFINED,
# To disable or hide an entity if it gets created, does not affect
# existing entities
disabled_by: RegistryEntryDisabler | None = None,
@@ -948,7 +1094,7 @@ class EntityRegistry(BaseRegistry):
entity_id = self.async_get_entity_id(domain, platform, unique_id)
if entity_id:
- return self.async_update_entity(
+ return self._async_update_entity(
entity_id,
capabilities=capabilities,
config_entry_id=config_entry_id,
@@ -956,9 +1102,11 @@ class EntityRegistry(BaseRegistry):
device_id=device_id,
entity_category=entity_category,
has_entity_name=has_entity_name,
+ object_id_base=object_id_base,
original_device_class=original_device_class,
original_icon=original_icon,
original_name=original_name,
+ suggested_object_id=suggested_object_id,
supported_features=supported_features,
translation_key=translation_key,
unit_of_measurement=unit_of_measurement,
@@ -1022,12 +1170,26 @@ class EntityRegistry(BaseRegistry):
name = None
options = get_initial_options() if get_initial_options else None
- if not entity_id:
- entity_id = self.async_generate_entity_id(
- domain,
- suggested_object_id
- or calculated_object_id
- or f"{platform}_{unique_id}",
+ def none_if_undefined[_T](value: _T | UndefinedType) -> _T | None:
+ """Return None if value is UNDEFINED, otherwise return value."""
+ return None if value is UNDEFINED else value
+
+ device_id = none_if_undefined(device_id)
+ has_entity_name_bool = none_if_undefined(has_entity_name) or False
+ object_id_base = none_if_undefined(object_id_base)
+ suggested_object_id = none_if_undefined(suggested_object_id)
+
+ if entity_id is None:
+ entity_id = self._async_generate_entity_id(
+ current_entity_id=None,
+ device_id=device_id,
+ domain=domain,
+ has_entity_name=has_entity_name_bool,
+ name=name,
+ object_id_base=object_id_base,
+ platform=platform,
+ suggested_object_id=suggested_object_id,
+ unique_id=unique_id,
)
if (
@@ -1038,10 +1200,6 @@ class EntityRegistry(BaseRegistry):
):
disabled_by = RegistryEntryDisabler.INTEGRATION
- def none_if_undefined[_T](value: _T | UndefinedType) -> _T | None:
- """Return None if value is UNDEFINED, otherwise return value."""
- return None if value is UNDEFINED else value
-
entry = RegistryEntry(
aliases=aliases,
area_id=area_id,
@@ -1051,16 +1209,17 @@ class EntityRegistry(BaseRegistry):
config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at,
device_class=device_class,
- device_id=none_if_undefined(device_id),
+ device_id=device_id,
disabled_by=disabled_by,
entity_category=none_if_undefined(entity_category),
entity_id=entity_id,
hidden_by=hidden_by,
- has_entity_name=none_if_undefined(has_entity_name) or False,
+ has_entity_name=has_entity_name_bool,
icon=icon,
id=entity_registry_id,
labels=labels,
name=name,
+ object_id_base=object_id_base,
options=options,
original_device_class=none_if_undefined(original_device_class),
original_icon=none_if_undefined(original_icon),
@@ -1255,11 +1414,13 @@ class EntityRegistry(BaseRegistry):
name: str | None | UndefinedType = UNDEFINED,
new_entity_id: str | UndefinedType = UNDEFINED,
new_unique_id: str | UndefinedType = UNDEFINED,
+ object_id_base: str | None | UndefinedType = UNDEFINED,
options: EntityOptionsType | UndefinedType = UNDEFINED,
original_device_class: str | None | UndefinedType = UNDEFINED,
original_icon: str | None | UndefinedType = UNDEFINED,
original_name: str | None | UndefinedType = UNDEFINED,
platform: str | None | UndefinedType = UNDEFINED,
+ suggested_object_id: str | None | UndefinedType = UNDEFINED,
supported_features: int | UndefinedType = UNDEFINED,
translation_key: str | None | UndefinedType = UNDEFINED,
unit_of_measurement: str | None | UndefinedType = UNDEFINED,
@@ -1286,11 +1447,13 @@ class EntityRegistry(BaseRegistry):
("has_entity_name", has_entity_name),
("labels", labels),
("name", name),
+ ("object_id_base", object_id_base),
("options", options),
("original_device_class", original_device_class),
("original_icon", original_icon),
("original_name", original_name),
("platform", platform),
+ ("suggested_object_id", suggested_object_id),
("supported_features", supported_features),
("translation_key", translation_key),
("unit_of_measurement", unit_of_measurement),
@@ -1552,6 +1715,7 @@ class EntityRegistry(BaseRegistry):
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name"],
+ object_id_base=entity.get("object_id_base"),
options=entity["options"],
original_device_class=entity["original_device_class"],
original_icon=entity["original_icon"],
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 3d7b99d571c..81d91ac8042 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1601,8 +1601,13 @@ class Script:
):
_referenced_extract_ids(data, target, referenced)
+ elif action == cv.SCRIPT_ACTION_CHECK_CONDITION:
+ referenced |= condition.async_extract_targets(step, target)
+
elif action == cv.SCRIPT_ACTION_CHOOSE:
for choice in step[CONF_CHOOSE]:
+ for cond in choice[CONF_CONDITIONS]:
+ referenced |= condition.async_extract_targets(cond, target)
Script._find_referenced_target(
target, referenced, choice[CONF_SEQUENCE]
)
@@ -1612,6 +1617,8 @@ class Script:
)
elif action == cv.SCRIPT_ACTION_IF:
+ for cond in step[CONF_IF]:
+ referenced |= condition.async_extract_targets(cond, target)
Script._find_referenced_target(target, referenced, step[CONF_THEN])
if CONF_ELSE in step:
Script._find_referenced_target(target, referenced, step[CONF_ELSE])
diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py
index b65ed720a82..334b7147e01 100644
--- a/homeassistant/helpers/target.py
+++ b/homeassistant/helpers/target.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import abc
from collections.abc import Callable
import dataclasses
import logging
@@ -268,65 +269,47 @@ def async_extract_referenced_entity_ids(
return selected
-class TargetStateChangeTracker:
- """Helper class to manage state change tracking for targets."""
+class TargetEntityChangeTracker(abc.ABC):
+ """Helper class to manage entity change tracking for targets."""
def __init__(
self,
hass: HomeAssistant,
target_selection: TargetSelection,
- action: Callable[[TargetStateChangedData], Any],
entity_filter: Callable[[set[str]], set[str]],
) -> None:
"""Initialize the state change tracker."""
self._hass = hass
self._target_selection = target_selection
- self._action = action
self._entity_filter = entity_filter
- self._state_change_unsub: CALLBACK_TYPE | None = None
self._registry_unsubs: list[CALLBACK_TYPE] = []
def async_setup(self) -> Callable[[], None]:
"""Set up the state change tracking."""
self._setup_registry_listeners()
- self._track_entities_state_change()
+ self._handle_target_update()
return self._unsubscribe
- def _track_entities_state_change(self) -> None:
- """Set up state change tracking for currently selected entities."""
+ @abc.abstractmethod
+ @callback
+ def _handle_entities_update(self, tracked_entities: set[str]) -> None:
+ """Called when there's an update to the list of entities of the tracked targets."""
+
+ @callback
+ def _handle_target_update(self, event: Event[Any] | None = None) -> None:
+ """Handle updates in the tracked targets."""
selected = async_extract_referenced_entity_ids(
self._hass, self._target_selection, expand_group=False
)
-
- tracked_entities = self._entity_filter(
+ filtered_entities = self._entity_filter(
selected.referenced | selected.indirectly_referenced
)
-
- @callback
- def state_change_listener(event: Event[EventStateChangedData]) -> None:
- """Handle state change events."""
- if (
- event.data["entity_id"] in selected.referenced
- or event.data["entity_id"] in selected.indirectly_referenced
- ):
- self._action(TargetStateChangedData(event, tracked_entities))
-
- _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
- self._state_change_unsub = async_track_state_change_event(
- self._hass, tracked_entities, state_change_listener
- )
+ self._handle_entities_update(filtered_entities)
def _setup_registry_listeners(self) -> None:
"""Set up listeners for registry changes that require resubscription."""
- @callback
- def resubscribe_state_change_event(event: Event[Any] | None = None) -> None:
- """Resubscribe to state change events when registry changes."""
- if self._state_change_unsub:
- self._state_change_unsub()
- self._track_entities_state_change()
-
# Subscribe to registry updates that can change the entities to track:
# - Entity registry: entity added/removed; entity labels changed; entity area changed.
# - Device registry: device labels changed; device area changed.
@@ -336,13 +319,13 @@ class TargetStateChangeTracker:
# changes don't affect which entities are tracked.
self._registry_unsubs = [
self._hass.bus.async_listen(
- er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event
+ er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_target_update
),
self._hass.bus.async_listen(
- dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event
+ dr.EVENT_DEVICE_REGISTRY_UPDATED, self._handle_target_update
),
self._hass.bus.async_listen(
- ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event
+ ar.EVENT_AREA_REGISTRY_UPDATED, self._handle_target_update
),
]
@@ -351,6 +334,42 @@ class TargetStateChangeTracker:
for registry_unsub in self._registry_unsubs:
registry_unsub()
self._registry_unsubs.clear()
+
+
+class TargetStateChangeTracker(TargetEntityChangeTracker):
+ """Helper class to manage state change tracking for targets."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ target_selection: TargetSelection,
+ action: Callable[[TargetStateChangedData], Any],
+ entity_filter: Callable[[set[str]], set[str]],
+ ) -> None:
+ """Initialize the state change tracker."""
+ super().__init__(hass, target_selection, entity_filter)
+ self._action = action
+ self._state_change_unsub: CALLBACK_TYPE | None = None
+
+ def _handle_entities_update(self, tracked_entities: set[str]) -> None:
+ """Handle the tracked entities."""
+
+ @callback
+ def state_change_listener(event: Event[EventStateChangedData]) -> None:
+ """Handle state change events."""
+ if event.data["entity_id"] in tracked_entities:
+ self._action(TargetStateChangedData(event, tracked_entities))
+
+ _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
+ if self._state_change_unsub:
+ self._state_change_unsub()
+ self._state_change_unsub = async_track_state_change_event(
+ self._hass, tracked_entities, state_change_listener
+ )
+
+ def _unsubscribe(self) -> None:
+ """Unsubscribe from all events."""
+ super()._unsubscribe()
if self._state_change_unsub:
self._state_change_unsub()
self._state_change_unsub = None
diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py
index baa0e379de0..225c6bcbc66 100644
--- a/homeassistant/helpers/trigger.py
+++ b/homeassistant/helpers/trigger.py
@@ -594,6 +594,8 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
_above: None | float | str
_below: None | float | str
+ _converter: Callable[[Any], float] = float
+
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -616,7 +618,7 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
return False
try:
- current_value = float(_attribute_value)
+ current_value = self._converter(_attribute_value)
except (TypeError, ValueError):
# Attribute is not a valid number, don't trigger
return False
@@ -706,6 +708,8 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
_upper_limit: float | str | None = None
_threshold_type: ThresholdType
+ _converter: Callable[[Any], float] = float
+
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
@@ -741,7 +745,7 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
return False
try:
- current_value = float(_attribute_value)
+ current_value = self._converter(_attribute_value)
except (TypeError, ValueError):
# Attribute is not a valid number, don't trigger
return False
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 35c1dce2227..98fd7c385b6 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -2,7 +2,7 @@
aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
-aiodns==3.6.1
+aiodns==4.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -36,10 +36,10 @@ fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.8.0
-hass-nabucasa==1.7.0
+hass-nabucasa==1.11.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
-home-assistant-frontend==20260107.0
+home-assistant-frontend==20260107.2
home-assistant-intents==2026.1.6
httpx==0.28.1
ifaddr==0.2.0
@@ -53,10 +53,10 @@ Pillow==12.0.0
propcache==0.4.1
psutil-home-assistant==0.0.1
PyJWT==2.10.1
-PyNaCl==1.6.0
+pymicro-vad==1.0.1
+PyNaCl==1.6.2
pyOpenSSL==25.3.0
pyserial==3.5
-pysilero-vad==3.1.0
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.0
diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py
index 6023ed7a4e6..75d3fbf46d1 100644
--- a/homeassistant/requirements.py
+++ b/homeassistant/requirements.py
@@ -9,8 +9,6 @@ import logging
import os
from typing import Any
-from packaging.requirements import Requirement
-
from .core import HomeAssistant, callback
from .exceptions import HomeAssistantError
from .helpers import singleton
@@ -260,8 +258,13 @@ class RequirementsManager:
"""
if DEPRECATED_PACKAGES or self.hass.config.skip_pip_packages:
all_requirements = {
- requirement_string: Requirement(requirement_string)
+ requirement_string: requirement_details
for requirement_string in requirements
+ if (
+ requirement_details := pkg_util.parse_requirement_safe(
+ requirement_string
+ )
+ )
}
if DEPRECATED_PACKAGES:
for requirement_string, requirement_details in all_requirements.items():
@@ -272,9 +275,12 @@ class RequirementsManager:
"" if is_built_in else "custom ",
name,
f"has requirement '{requirement_string}' which {reason}",
- f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
- if breaks_in_ha_version
- else "Please",
+ (
+ "This will stop working in Home Assistant "
+ f"{breaks_in_ha_version}, please"
+ if breaks_in_ha_version
+ else "Please"
+ ),
async_suggest_report_issue(
self.hass, integration_domain=name
),
diff --git a/homeassistant/strings.json b/homeassistant/strings.json
index 20b56507b9d..b32420b974b 100644
--- a/homeassistant/strings.json
+++ b/homeassistant/strings.json
@@ -85,6 +85,9 @@
"timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error"
},
+ "initiate_flow": {
+ "account": "Add account"
+ },
"title": {
"oauth2_pick_implementation": "Pick authentication method",
"reauth": "Authentication expired for {name}",
diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py
index 2c0ed363eef..eebcdb2bba6 100644
--- a/homeassistant/util/package.py
+++ b/homeassistant/util/package.py
@@ -44,6 +44,39 @@ def get_installed_versions(specifiers: set[str]) -> set[str]:
return {specifier for specifier in specifiers if is_installed(specifier)}
+def parse_requirement_safe(requirement_str: str) -> Requirement | None:
+ """Parse a requirement string into a Requirement object.
+
+ expected input is a pip compatible package specifier (requirement string)
+ e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" or "package@git+https://..."
+
+ For backward compatibility, it also accepts a URL with a fragment
+ e.g. "git+https://github.com/pypa/pip#pip>=1"
+
+ Returns None on a badly-formed requirement string.
+ """
+ try:
+ return Requirement(requirement_str)
+ except InvalidRequirement:
+ if "#" not in requirement_str:
+ _LOGGER.error("Invalid requirement '%s'", requirement_str)
+ return None
+
+ # This is likely a URL with a fragment
+ # example: git+https://github.com/pypa/pip#pip>=1
+
+ # fragment support was originally used to install zip files, and
+ # we no longer do this in Home Assistant. However, custom
+ # components started using it to install packages from git
+ # urls which would make it would be a breaking change to
+ # remove it.
+ try:
+ return Requirement(urlparse(requirement_str).fragment)
+ except InvalidRequirement:
+ _LOGGER.error("Invalid requirement '%s'", requirement_str)
+ return None
+
+
def is_installed(requirement_str: str) -> bool:
"""Check if a package is installed and will be loaded when we import it.
@@ -56,26 +89,8 @@ def is_installed(requirement_str: str) -> bool:
Returns True when the requirement is met.
Returns False when the package is not installed or doesn't meet req.
"""
- try:
- req = Requirement(requirement_str)
- except InvalidRequirement:
- if "#" not in requirement_str:
- _LOGGER.error("Invalid requirement '%s'", requirement_str)
- return False
-
- # This is likely a URL with a fragment
- # example: git+https://github.com/pypa/pip#pip>=1
-
- # fragment support was originally used to install zip files, and
- # we no longer do this in Home Assistant. However, custom
- # components started using it to install packages from git
- # urls which would make it would be a breaking change to
- # remove it.
- try:
- req = Requirement(urlparse(requirement_str).fragment)
- except InvalidRequirement:
- _LOGGER.error("Invalid requirement '%s'", requirement_str)
- return False
+ if (req := parse_requirement_safe(requirement_str)) is None:
+ return False
try:
if (installed_version := version(req.name)) is None:
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 68b27b98cd8..daae62861a4 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -103,6 +103,9 @@ _AMBIENT_IDEAL_GAS_MOLAR_VOLUME = ( # m3⋅mol⁻¹
)
# Molar masses in g⋅mol⁻¹
_CARBON_MONOXIDE_MOLAR_MASS = 28.01
+_NITROGEN_DIOXIDE_MOLAR_MASS = 46.0055
+_OZONE_MOLAR_MASS = 48.00
+_SULPHUR_DIOXIDE_MOLAR_MASS = 64.066
class BaseUnitConverter:
@@ -193,6 +196,7 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
UNIT_CLASS = "carbon_monoxide"
_UNIT_CONVERSION: dict[str | None, float] = {
+ CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_PARTS_PER_MILLION: 1e6,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
@@ -202,12 +206,45 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
),
}
VALID_UNITS = {
+ CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
+class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
+ """Convert nitrogen dioxide ratio to mass per volume."""
+
+ UNIT_CLASS = "nitrogen_dioxide"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ CONCENTRATION_PARTS_PER_BILLION: 1e9,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
+ _NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
+ ),
+ }
+ VALID_UNITS = {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ }
+
+
+class SulphurDioxideConcentrationConverter(BaseUnitConverter):
+ """Convert sulphur dioxide ratio to mass per volume."""
+
+ UNIT_CLASS = "sulphur_dioxide"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ CONCENTRATION_PARTS_PER_BILLION: 1e9,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
+ _SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
+ ),
+ }
+ VALID_UNITS = {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ }
+
+
class DataRateConverter(BaseUnitConverter):
"""Utility to convert data rate values."""
@@ -526,6 +563,22 @@ class ReactivePowerConverter(BaseUnitConverter):
}
+class OzoneConcentrationConverter(BaseUnitConverter):
+ """Convert ozone ratio to mass per volume."""
+
+ UNIT_CLASS = "ozone"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ CONCENTRATION_PARTS_PER_BILLION: 1e9,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
+ _OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
+ ),
+ }
+ VALID_UNITS = {
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ }
+
+
class SpeedConverter(BaseUnitConverter):
"""Utility to convert speed values."""
diff --git a/mypy.ini b/mypy.ini
index e21f8fd44c3..da80d719894 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -3826,6 +3826,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.pooldose.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.portainer.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4296,6 +4306,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.saunum.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.scene.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py
index 4b22d1284d7..54b755fc98c 100644
--- a/pylint/plugins/hass_enforce_type_hints.py
+++ b/pylint/plugins/hass_enforce_type_hints.py
@@ -16,7 +16,7 @@ from homeassistant.const import Platform
if TYPE_CHECKING:
# InferenceResult is available only from astroid >= 2.12.0
- # pre-commit should still work on out of date environments
+ # prek should still work on out of date environments
from astroid.typing import InferenceResult
_COMMON_ARGUMENTS: dict[str, list[str]] = {
@@ -168,7 +168,6 @@ _TEST_FIXTURES: dict[str, list[str] | str] = {
"service_calls": "list[ServiceCall]",
"snapshot": "SnapshotAssertion",
"socket_enabled": "None",
- "stub_blueprint_populate": "None",
"tmp_path": "Path",
"tmpdir": "py.path.local",
"tts_mutagen_mock": "MagicMock",
@@ -700,6 +699,7 @@ _ENTITY_MATCH: list[TypeHintMatch] = [
TypeHintMatch(
function_name="device_class",
return_type=["str", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="unit_of_measurement",
@@ -931,6 +931,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["BinarySensorDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="is_on",
@@ -954,6 +955,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["ButtonDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="press",
@@ -1222,6 +1224,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="preset_mode",
return_type=["str", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="preset_modes",
@@ -1366,6 +1369,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["CoverDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="current_cover_position",
@@ -1598,6 +1602,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="preset_mode",
return_type=["str", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="preset_modes",
@@ -1991,6 +1996,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["MediaPlayerDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="state",
@@ -2314,6 +2320,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
kwargs_type="Any",
return_type=None,
has_async_counterpart=True,
+ mandatory=True,
),
],
),
@@ -2333,6 +2340,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["NumberDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="capability_attributes",
@@ -2507,14 +2515,17 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["SensorDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="state_class",
return_type=["SensorStateClass", "str", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="last_reset",
return_type=["datetime", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="native_value",
@@ -2631,6 +2642,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["SwitchDeviceClass", None],
+ mandatory=True,
),
],
),
@@ -2735,6 +2747,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="device_class",
return_type=["UpdateDeviceClass", None],
+ mandatory=True,
),
TypeHintMatch(
function_name="in_progress",
diff --git a/pyproject.toml b/pyproject.toml
index c5bcf7dca57..bb5cb34f3eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,7 @@ classifiers = [
]
requires-python = ">=3.13.2"
dependencies = [
- "aiodns==3.6.1",
+ "aiodns==4.0.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
@@ -48,7 +48,7 @@ dependencies = [
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==1.7.0",
+ "hass-nabucasa==1.11.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",
diff --git a/requirements.txt b/requirements.txt
index d7f3ca26675..754d7543fb0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,7 @@
-c homeassistant/package_constraints.txt
# Home Assistant Core
-aiodns==3.6.1
+aiodns==4.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -24,7 +24,7 @@ cronsim==2.7
cryptography==46.0.2
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
-hass-nabucasa==1.7.0
+hass-nabucasa==1.11.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.1.6
@@ -39,8 +39,8 @@ Pillow==12.0.0
propcache==0.4.1
psutil-home-assistant==0.0.1
PyJWT==2.10.1
+pymicro-vad==1.0.1
pyOpenSSL==25.3.0
-pysilero-vad==3.1.0
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.0
diff --git a/requirements_all.txt b/requirements_all.txt
index a1b5c0d86ef..db0a235631e 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -70,7 +70,7 @@ PyMicroBot==0.0.23
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
-PyNaCl==1.6.0
+PyNaCl==1.6.2
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.homekit
@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
-PySrDaliGateway==0.18.0
+PySrDaliGateway==0.19.3
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -187,7 +187,7 @@ aioairq==0.4.7
aioairzone-cloud==0.7.2
# homeassistant.components.airzone
-aioairzone==1.0.4
+aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==11.0.2
@@ -231,7 +231,7 @@ aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
# homeassistant.components.dnsip
-aiodns==3.6.1
+aiodns==4.0.0
# homeassistant.components.duke_energy
aiodukeenergy==0.3.0
@@ -319,7 +319,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
-aiomealie==1.1.1
+aiomealie==1.2.0
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -334,7 +334,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
-aiontfy==0.6.1
+aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -703,7 +703,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
-bthome-ble==3.17.0
+bthome-ble==3.16.0
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -739,7 +739,7 @@ colorlog==6.10.1
colorthief==0.2.1
# homeassistant.components.compit
-compit-inext-api==0.3.4
+compit-inext-api==0.4.2
# homeassistant.components.concord232
concord232==0.15.1
@@ -785,7 +785,6 @@ decora-wifi==1.4
deebot-client==17.0.1
# homeassistant.components.ihc
-# homeassistant.components.namecheapdns
# homeassistant.components.ohmconnect
# homeassistant.components.sonos
defusedxml==0.7.1
@@ -842,7 +841,7 @@ dynalite-panel==0.0.4
eagle100==0.1.1
# homeassistant.components.easyenergy
-easyenergy==2.1.2
+easyenergy==2.2.0
# homeassistant.components.ebusd
ebusdpy==0.0.17
@@ -1011,7 +1010,7 @@ freebox-api==1.2.2
freesms==0.2.0
# homeassistant.components.fressnapf_tracker
-fressnapftracker==0.2.0
+fressnapftracker==0.2.1
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -1093,7 +1092,7 @@ google-cloud-speech==2.31.1
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-genai==1.56.0
+google-genai==1.59.0
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -1105,7 +1104,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
-google_air_quality_api==2.1.2
+google_air_quality_api==3.0.0
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1127,7 +1126,7 @@ gpiozero==1.6.2
gps3==0.33.3
# homeassistant.components.gree
-greeclimate==2.1.0
+greeclimate==2.1.1
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1172,7 +1171,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
-hass-nabucasa==1.7.0
+hass-nabucasa==1.11.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -1216,7 +1215,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
-home-assistant-frontend==20260107.0
+home-assistant-frontend==20260107.2
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -1352,7 +1351,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.12.30.151231
+knx-frontend==2026.1.15.112308
# homeassistant.components.konnected
konnected==1.2.0
@@ -1455,7 +1454,7 @@ mbddns==0.1.2
mcp==1.14.1
# homeassistant.components.minecraft_server
-mcstatus==12.0.6
+mcstatus==12.1.0
# homeassistant.components.meater
meater-python==0.0.8
@@ -1647,7 +1646,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
-onedrive-personal-sdk==0.0.17
+onedrive-personal-sdk==0.1.1
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1684,7 +1683,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.16.1
+opower==0.16.4
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -1867,7 +1866,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
-pyTibber==0.34.1
+pyTibber==0.35.0
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2022,7 +2021,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
-pyenphase==2.4.2
+pyenphase==2.4.3
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -2046,7 +2045,7 @@ pyfibaro==0.8.3
pyfido==2.1.2
# homeassistant.components.firefly_iii
-pyfirefly==0.1.10
+pyfirefly==0.1.12
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -2097,7 +2096,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
-pyicloud==2.2.0
+pyicloud==2.3.0
# homeassistant.components.insteon
pyinsteon==1.6.4
@@ -2133,7 +2132,7 @@ pyitachip2ir==0.0.7
pyituran==0.1.5
# homeassistant.components.jvc_projector
-pyjvcprojector==1.1.3
+pyjvcprojector==2.0.0
# homeassistant.components.kaleidescape
pykaleidescape==1.0.2
@@ -2148,7 +2147,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
-pykoplenti==1.3.0
+pykoplenti==1.5.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -2201,6 +2200,9 @@ pymediaroom==0.6.5.4
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.1.0
+# homeassistant.components.assist_pipeline
+pymicro-vad==1.0.1
+
# homeassistant.components.miele
pymiele==0.6.1
@@ -2232,7 +2234,7 @@ pynetgear==0.10.10
pynetio==0.1.9.1
# homeassistant.components.nina
-pynina==0.3.6
+pynina==1.0.2
# homeassistant.components.nintendo_parental_controls
pynintendoauth==1.0.2
@@ -2318,7 +2320,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
-pyportainer==1.0.22
+pyportainer==1.0.23
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2375,7 +2377,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.saunum
-pysaunum==0.1.0
+pysaunum==0.3.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2408,9 +2410,6 @@ pysiaalarm==3.1.1
# homeassistant.components.signal_messenger
pysignalclirestapi==0.3.24
-# homeassistant.components.assist_pipeline
-pysilero-vad==3.1.0
-
# homeassistant.components.sky_hub
pyskyqhub==0.1.4
@@ -2481,7 +2480,7 @@ python-awair==0.2.5
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
-python-bsblan==3.1.6
+python-bsblan==4.1.0
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2520,7 +2519,7 @@ python-google-weather-api==0.0.4
python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
-python-homewizard-energy==10.0.0
+python-homewizard-energy==10.0.1
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2575,7 +2574,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
-python-pooldose==0.8.1
+python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2596,7 +2595,7 @@ python-snoo==0.8.3
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.18.15
+python-tado==0.18.16
# homeassistant.components.technove
python-technove==2.0.0
@@ -2708,7 +2707,7 @@ qbittorrent-api==2024.9.67
qbusmqttapi==1.4.2
# homeassistant.components.qingping
-qingping-ble==1.0.1
+qingping-ble==1.1.0
# homeassistant.components.qnap
qnapstats==0.4.0
@@ -2956,7 +2955,7 @@ surepy==0.9.0
swisshydrodata==0.1.0
# homeassistant.components.switchbot_cloud
-switchbot-api==2.9.0
+switchbot-api==2.10.0
# homeassistant.components.synology_srm
synology-srm==0.2.0
@@ -2991,7 +2990,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==1.3.2
+tesla-fleet-api==1.4.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -3081,7 +3080,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==8.0.0
+uiprotect==10.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -3116,7 +3115,7 @@ uvcclient==0.12.1
vacuum-map-parser-roborock==0.1.4
# homeassistant.components.vallox
-vallox-websocket-api==5.3.0
+vallox-websocket-api==6.0.0
# homeassistant.components.vegehub
vegehub==0.1.26
@@ -3146,7 +3145,7 @@ visionpluspython==1.0.2
vobject==0.9.9
# homeassistant.components.voip
-voip-utils==0.3.4
+voip-utils==0.3.5
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -3171,7 +3170,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
-waterfurnace==1.2.0
+waterfurnace==1.4.0
# homeassistant.components.watergate
watergate-local-api==2025.1.0
@@ -3216,10 +3215,10 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
-xiaomi-ble==1.4.1
+xiaomi-ble==1.5.0
# homeassistant.components.knx
-xknx==3.13.0
+xknx==3.14.0
# homeassistant.components.knx
xknxproject==3.8.2
@@ -3292,7 +3291,7 @@ ziggo-mediabox-xl==1.1.0
zm-py==0.5.4
# homeassistant.components.zwave_js
-zwave-js-server-python==0.67.1
+zwave-js-server-python==0.68.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test.txt b/requirements_test.txt
index 7126bb9371a..0e66d530eff 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -15,7 +15,7 @@ librt==0.2.1
license-expression==30.4.3
mock-open==1.4.0
mypy-dev==1.19.0a4
-pre-commit==4.2.0
+prek==0.2.28
pydantic==2.12.2
pylint==4.0.1
pylint-per-file-ignores==1.4.0
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 9abf72afe0a..a7879b77798 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -70,7 +70,7 @@ PyMicroBot==0.0.23
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
-PyNaCl==1.6.0
+PyNaCl==1.6.2
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.homekit
@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
-PySrDaliGateway==0.18.0
+PySrDaliGateway==0.19.3
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -178,7 +178,7 @@ aioairq==0.4.7
aioairzone-cloud==0.7.2
# homeassistant.components.airzone
-aioairzone==1.0.4
+aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==11.0.2
@@ -222,7 +222,7 @@ aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
# homeassistant.components.dnsip
-aiodns==3.6.1
+aiodns==4.0.0
# homeassistant.components.duke_energy
aiodukeenergy==0.3.0
@@ -304,7 +304,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
-aiomealie==1.1.1
+aiomealie==1.2.0
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -319,7 +319,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
-aiontfy==0.6.1
+aiontfy==0.7.0
# homeassistant.components.nut
aionut==4.3.4
@@ -633,7 +633,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
-bthome-ble==3.17.0
+bthome-ble==3.16.0
# homeassistant.components.buienradar
buienradar==1.0.6
@@ -654,7 +654,7 @@ colorlog==6.10.1
colorthief==0.2.1
# homeassistant.components.compit
-compit-inext-api==0.3.4
+compit-inext-api==0.4.2
# homeassistant.components.concord232
concord232==0.15.1
@@ -694,7 +694,6 @@ debugpy==1.8.17
deebot-client==17.0.1
# homeassistant.components.ihc
-# homeassistant.components.namecheapdns
# homeassistant.components.ohmconnect
# homeassistant.components.sonos
defusedxml==0.7.1
@@ -748,7 +747,7 @@ dynalite-panel==0.0.4
eagle100==0.1.1
# homeassistant.components.easyenergy
-easyenergy==2.1.2
+easyenergy==2.2.0
# homeassistant.components.egauge
egauge-async==0.4.0
@@ -890,7 +889,7 @@ forecast-solar==4.2.0
freebox-api==1.2.2
# homeassistant.components.fressnapf_tracker
-fressnapftracker==0.2.0
+fressnapftracker==0.2.1
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -969,7 +968,7 @@ google-cloud-speech==2.31.1
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-genai==1.56.0
+google-genai==1.59.0
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -981,7 +980,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
-google_air_quality_api==2.1.2
+google_air_quality_api==3.0.0
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1000,7 +999,7 @@ govee-local-api==2.3.0
gps3==0.33.3
# homeassistant.components.gree
-greeclimate==2.1.0
+greeclimate==2.1.1
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1042,7 +1041,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
-hass-nabucasa==1.7.0
+hass-nabucasa==1.11.0
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
@@ -1074,7 +1073,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
-home-assistant-frontend==20260107.0
+home-assistant-frontend==20260107.2
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -1186,7 +1185,7 @@ kegtron-ble==1.0.2
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.12.30.151231
+knx-frontend==2026.1.15.112308
# homeassistant.components.konnected
konnected==1.2.0
@@ -1268,7 +1267,7 @@ mbddns==0.1.2
mcp==1.14.1
# homeassistant.components.minecraft_server
-mcstatus==12.0.6
+mcstatus==12.1.0
# homeassistant.components.meater
meater-python==0.0.8
@@ -1430,7 +1429,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
-onedrive-personal-sdk==0.0.17
+onedrive-personal-sdk==0.1.1
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1458,7 +1457,7 @@ openrgb-python==0.3.6
openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.16.1
+opower==0.16.4
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -1523,6 +1522,9 @@ prometheus-client==0.21.0
# homeassistant.components.prowl
prowlpy==1.1.1
+# homeassistant.components.proxmoxve
+proxmoxer==2.0.1
+
# homeassistant.components.hardware
# homeassistant.components.recorder
# homeassistant.components.systemmonitor
@@ -1598,7 +1600,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
-pyTibber==0.34.1
+pyTibber==0.35.0
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1717,7 +1719,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
-pyenphase==2.4.2
+pyenphase==2.4.3
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1735,7 +1737,7 @@ pyfibaro==0.8.3
pyfido==2.1.2
# homeassistant.components.firefly_iii
-pyfirefly==0.1.10
+pyfirefly==0.1.12
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -1777,7 +1779,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
-pyicloud==2.2.0
+pyicloud==2.3.0
# homeassistant.components.insteon
pyinsteon==1.6.4
@@ -1804,7 +1806,7 @@ pyisy==3.4.1
pyituran==0.1.5
# homeassistant.components.jvc_projector
-pyjvcprojector==1.1.3
+pyjvcprojector==2.0.0
# homeassistant.components.kaleidescape
pykaleidescape==1.0.2
@@ -1819,7 +1821,7 @@ pykmtronic==0.3.0
pykodi==0.2.7
# homeassistant.components.kostal_plenticore
-pykoplenti==1.3.0
+pykoplenti==1.5.0
# homeassistant.components.kraken
pykrakenapi==0.1.8
@@ -1863,6 +1865,9 @@ pymata-express==1.19
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.1.0
+# homeassistant.components.assist_pipeline
+pymicro-vad==1.0.1
+
# homeassistant.components.miele
pymiele==0.6.1
@@ -1885,7 +1890,7 @@ pynecil==4.2.1
pynetgear==0.10.10
# homeassistant.components.nina
-pynina==0.3.6
+pynina==1.0.2
# homeassistant.components.nintendo_parental_controls
pynintendoauth==1.0.2
@@ -1962,7 +1967,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
-pyportainer==1.0.22
+pyportainer==1.0.23
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2007,7 +2012,7 @@ pyrympro==0.0.9
pysabnzbd==1.1.1
# homeassistant.components.saunum
-pysaunum==0.1.0
+pysaunum==0.3.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2034,9 +2039,6 @@ pysiaalarm==3.1.1
# homeassistant.components.signal_messenger
pysignalclirestapi==0.3.24
-# homeassistant.components.assist_pipeline
-pysilero-vad==3.1.0
-
# homeassistant.components.sma
pysma==1.1.0
@@ -2098,7 +2100,7 @@ python-MotionMount==2.3.0
python-awair==0.2.5
# homeassistant.components.bsblan
-python-bsblan==3.1.6
+python-bsblan==4.1.0
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2116,7 +2118,7 @@ python-google-weather-api==0.0.4
python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
-python-homewizard-energy==10.0.0
+python-homewizard-energy==10.0.1
# homeassistant.components.izone
python-izone==1.2.9
@@ -2165,7 +2167,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
-python-pooldose==0.8.1
+python-pooldose==0.8.2
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2183,7 +2185,7 @@ python-snoo==0.8.3
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.18.15
+python-tado==0.18.16
# homeassistant.components.technove
python-technove==2.0.0
@@ -2277,7 +2279,7 @@ qbittorrent-api==2024.9.67
qbusmqttapi==1.4.2
# homeassistant.components.qingping
-qingping-ble==1.0.1
+qingping-ble==1.1.0
# homeassistant.components.qnap
qnapstats==0.4.0
@@ -2477,7 +2479,7 @@ subarulink==0.7.15
surepy==0.9.0
# homeassistant.components.switchbot_cloud
-switchbot-api==2.9.0
+switchbot-api==2.10.0
# homeassistant.components.system_bridge
systembridgeconnector==5.3.1
@@ -2497,7 +2499,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==1.3.2
+tesla-fleet-api==1.4.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2575,7 +2577,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==8.0.0
+uiprotect==10.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2604,7 +2606,7 @@ uvcclient==0.12.1
vacuum-map-parser-roborock==0.1.4
# homeassistant.components.vallox
-vallox-websocket-api==5.3.0
+vallox-websocket-api==6.0.0
# homeassistant.components.vegehub
vegehub==0.1.26
@@ -2634,7 +2636,7 @@ visionpluspython==1.0.2
vobject==0.9.9
# homeassistant.components.voip
-voip-utils==0.3.4
+voip-utils==0.3.5
# homeassistant.components.volvo
volvocarsapi==0.4.3
@@ -2689,10 +2691,10 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
-xiaomi-ble==1.4.1
+xiaomi-ble==1.5.0
# homeassistant.components.knx
-xknx==3.13.0
+xknx==3.14.0
# homeassistant.components.knx
xknxproject==3.8.2
@@ -2747,7 +2749,7 @@ zeversolar==0.3.2
zha==0.0.84
# homeassistant.components.zwave_js
-zwave-js-server-python==0.67.1
+zwave-js-server-python==0.68.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 05f89f250f6..de53164aed0 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -427,7 +427,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if config.action == "generate" and manifests_resorted:
subprocess.run(
[
- "pre-commit",
+ "prek",
"run",
"--hook-stage",
"manual",
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index f6d6020864e..bc97db995f3 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -1390,7 +1390,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"freebox",
"freedns",
"freedompro",
- "fritz",
"fritzbox",
"fritzbox_callmonitor",
"frontier_silicon",
@@ -2000,7 +1999,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"touchline",
"touchline_sl",
"tplink_lte",
- "tplink_omada",
"traccar",
"traccar_server",
"tractive",
diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py
index f13654d751c..ee02896b6f0 100644
--- a/script/hassfest/requirements.py
+++ b/script/hassfest/requirements.py
@@ -224,6 +224,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"sense": {"sense-energy": {"async-timeout"}},
"slimproto": {"aioslimproto": {"async-timeout"}},
"surepetcare": {"surepy": {"async-timeout"}},
+ "tami4": {
+ # https://github.com/SeleniumHQ/selenium/issues/16943
+ # tami4 > selenium > types*
+ "selenium": {"types-certifi", "types-urllib3"},
+ },
"travisci": {
# https://github.com/menegazzo/travispy seems to be unmaintained
# and unused https://www.home-assistant.io/integrations/travisci
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 5f73fc32e59..7c578a5e6d8 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -214,6 +214,10 @@ def gen_data_entry_schema(
vol.Required("user"): translation_value_validator,
str: translation_value_validator,
}
+ else:
+ schema[vol.Optional("initiate_flow")] = {
+ vol.Required("user"): translation_value_validator,
+ }
if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator
elif flow_title == REMOVED:
diff --git a/script/lint b/script/lint
index 26b6db705f1..5a7e9314b59 100755
--- a/script/lint
+++ b/script/lint
@@ -15,7 +15,7 @@ printf "%s\n" $files
echo "=============="
echo "LINT with ruff"
echo "=============="
-pre-commit run ruff-check --files $files
+prek run ruff-check --files $files
echo "================"
echo "LINT with pylint"
echo "================"
diff --git a/script/lint_and_test.py b/script/lint_and_test.py
index 44d9e5d8eb7..ed485f4b1ed 100755
--- a/script/lint_and_test.py
+++ b/script/lint_and_test.py
@@ -119,7 +119,7 @@ async def pylint(files):
async def ruff(files):
"""Exec ruff."""
- _, log = await async_exec("pre-commit", "run", "ruff", "--files", *files)
+ _, log = await async_exec("prek", "run", "ruff", "--files", *files)
res = []
for line in log.splitlines():
line = line.split(":")
diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py
index d90e01c3ebd..c9264aa660f 100644
--- a/script/scaffold/gather_info.py
+++ b/script/scaffold/gather_info.py
@@ -24,7 +24,12 @@ def gather_info(arguments) -> Info:
info = _gather_info(
{
"domain": {
- "prompt": "What is the domain?",
+ "prompt": (
+ """What is the domain?
+
+Hint: The domain is a short name consisting of characters and underscores.
+This domain has to be unique, cannot be changed, and has to match the directory name of the integration."""
+ ),
"validators": [
CHECK_EMPTY,
[
@@ -72,13 +77,8 @@ def gather_new_integration(determine_auth: bool) -> Info:
},
"codeowner": {
"prompt": "What is your GitHub handle?",
- "validators": [
- CHECK_EMPTY,
- [
- 'GitHub handles need to start with an "@"',
- lambda value: value.startswith("@"),
- ],
- ],
+ "validators": [CHECK_EMPTY],
+ "converter": lambda value: value if value.startswith("@") else f"@{value}",
},
"requirement": {
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
diff --git a/script/setup b/script/setup
index 1fd61aa9b71..9b8984f4997 100755
--- a/script/setup
+++ b/script/setup
@@ -31,7 +31,7 @@ fi
script/bootstrap
-pre-commit install
+prek install
hass --script ensure_config -c config
diff --git a/script/version_bump.py b/script/version_bump.py
index 2a7d82937f1..91571bad169 100755
--- a/script/version_bump.py
+++ b/script/version_bump.py
@@ -2,15 +2,19 @@
"""Helper script to bump the current version."""
import argparse
+from copy import replace
from pathlib import Path
import re
import subprocess
+import packaging
from packaging.version import Version
from homeassistant import const
from homeassistant.util import dt as dt_util
+_PACKAGING_VERSION_BELOW_26 = Version(packaging.__version__) < Version("26.0dev0")
+
def _bump_release(release, bump_type):
"""Bump a release tuple consisting of 3 numbers."""
@@ -25,6 +29,13 @@ def _bump_release(release, bump_type):
return major, minor, patch
+def _get_dev_change(dev: int) -> int | tuple[str, int]:
+ """Return the dev change based on packaging version."""
+ if _PACKAGING_VERSION_BELOW_26:
+ return ("dev", dev)
+ return dev
+
+
def bump_version(
version: Version, bump_type: str, *, nightly_version: str | None = None
) -> Version:
@@ -58,9 +69,10 @@ def bump_version(
# Convert 0.67.3.b5 to 0.67.4.dev0
# Convert 0.67.3.dev0 to 0.67.3.dev1
if version.is_devrelease:
- to_change["dev"] = ("dev", version.dev + 1)
+ to_change["dev"] = _get_dev_change(version.dev + 1)
else:
- to_change["pre"] = ("dev", 0)
+ to_change["dev"] = _get_dev_change(0)
+ to_change["pre"] = None
to_change["release"] = _bump_release(version.release, "minor")
elif bump_type == "beta":
@@ -99,14 +111,19 @@ def bump_version(
raise ValueError("Nightly version must be a dev version")
new_dev = new_version.dev
- to_change["dev"] = ("dev", new_dev)
+ if not isinstance(new_dev, int):
+ new_dev = int(new_dev)
+ to_change["dev"] = _get_dev_change(new_dev)
else:
raise ValueError(f"Unsupported type: {bump_type}")
- temp = Version("0")
- temp._version = version._version._replace(**to_change) # noqa: SLF001
- return Version(str(temp))
+ if _PACKAGING_VERSION_BELOW_26:
+ temp = Version("0")
+ temp._version = version._version._replace(**to_change) # noqa: SLF001
+ return Version(str(temp))
+
+ return replace(version, **to_change)
def write_version(version):
diff --git a/tests/common.py b/tests/common.py
index dbef36f4672..efda5a6a1c3 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -697,6 +697,7 @@ class RegistryEntryWithDefaults(er.RegistryEntry):
converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc]
)
has_entity_name: bool = attr.ib(default=False)
+ object_id_base: str | None = attr.ib(default=None)
options: er.ReadOnlyEntityOptionsType = attr.ib(
default=None, converter=er._protect_entity_options
)
diff --git a/tests/components/__init__.py b/tests/components/__init__.py
index 8c251b7d27b..6eb902d391a 100644
--- a/tests/components/__init__.py
+++ b/tests/components/__init__.py
@@ -5,6 +5,8 @@ from enum import StrEnum
import itertools
from typing import Any, TypedDict
+import pytest
+
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
@@ -12,6 +14,7 @@ from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ABOVE,
CONF_BELOW,
+ CONF_CONDITION,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
@@ -27,6 +30,10 @@ from homeassistant.helpers import (
floor_registry as fr,
label_registry as lr,
)
+from homeassistant.helpers.condition import (
+ ConditionCheckerTypeOptional,
+ async_from_config as async_condition_from_config,
+)
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
@@ -93,6 +100,13 @@ async def target_entities(
suggested_object_id=f"device_{domain}",
device_id=device.id,
)
+ entity_reg.async_get_or_create(
+ domain=domain,
+ platform="test",
+ unique_id=f"{domain}_device2",
+ suggested_object_id=f"device2_{domain}",
+ device_id=device.id,
+ )
entity_reg.async_get_or_create(
domain=domain,
platform="test",
@@ -123,9 +137,11 @@ async def target_entities(
return {
"included": [
f"{domain}.standalone_{domain}",
+ f"{domain}.standalone2_{domain}",
f"{domain}.label_{domain}",
f"{domain}.area_{domain}",
f"{domain}.device_{domain}",
+ f"{domain}.device2_{domain}",
],
"excluded": [
f"{domain}.standalone_{domain}_excluded",
@@ -143,33 +159,194 @@ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
"""
return [
(
- {CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
+ {
+ CONF_ENTITY_ID: [
+ f"{domain}.standalone_{domain}",
+ f"{domain}.standalone2_{domain}",
+ ]
+ },
f"{domain}.standalone_{domain}",
- 1,
+ 2,
),
- ({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
- ({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
- ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
- ({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
- ({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
- ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
- ({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 3),
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 3),
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 3),
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 3),
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 3),
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 3),
+ ({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 2),
]
class _StateDescription(TypedDict):
- """Test state and expected service call count."""
+ """Test state with attributes."""
state: str | None
attributes: dict
-class StateDescription(TypedDict):
+class TriggerStateDescription(TypedDict):
"""Test state and expected service call count."""
- included: _StateDescription
- excluded: _StateDescription
- count: int
+ included: _StateDescription # State for entities meant to be targeted
+ excluded: _StateDescription # State for entities not meant to be targeted
+ count: int # Expected service call count
+
+
+class ConditionStateDescription(TypedDict):
+ """Test state and expected condition evaluation."""
+
+ included: _StateDescription # State for entities meant to be targeted
+ excluded: _StateDescription # State for entities not meant to be targeted
+
+ condition_true: bool # If the condition is expected to evaluate to true
+ condition_true_first_entity: bool # If the condition is expected to evaluate to true for the first targeted entity
+
+
+def _parametrize_condition_states(
+ *,
+ condition: str,
+ condition_options: dict[str, Any] | None = None,
+ target_states: list[str | None | tuple[str | None, dict]],
+ other_states: list[str | None | tuple[str | None, dict]],
+ additional_attributes: dict | None,
+ condition_true_if_invalid: bool,
+) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
+ """Parametrize states and expected condition evaluations.
+
+ The target_states and other_states iterables are either iterables of
+ states or iterables of (state, attributes) tuples.
+
+ Returns a list of tuples with (condition, condition options, list of states),
+ where states is a list of ConditionStateDescription dicts.
+ """
+
+ additional_attributes = additional_attributes or {}
+ condition_options = condition_options or {}
+
+ def state_with_attributes(
+ state: str | None | tuple[str | None, dict],
+ condition_true: bool,
+ condition_true_first_entity: bool,
+ ) -> ConditionStateDescription:
+ """Return ConditionStateDescription dict."""
+ if isinstance(state, str) or state is None:
+ return {
+ "included": {
+ "state": state,
+ "attributes": additional_attributes,
+ },
+ "excluded": {
+ "state": state,
+ "attributes": {},
+ },
+ "condition_true": condition_true,
+ "condition_true_first_entity": condition_true_first_entity,
+ }
+ return {
+ "included": {
+ "state": state[0],
+ "attributes": state[1] | additional_attributes,
+ },
+ "excluded": {
+ "state": state[0],
+ "attributes": state[1],
+ },
+ "condition_true": condition_true,
+ "condition_true_first_entity": condition_true_first_entity,
+ }
+
+ return [
+ (
+ condition,
+ condition_options,
+ list(
+ itertools.chain(
+ (state_with_attributes(None, condition_true_if_invalid, True),),
+ (
+ state_with_attributes(
+ STATE_UNAVAILABLE, condition_true_if_invalid, True
+ ),
+ ),
+ (
+ state_with_attributes(
+ STATE_UNKNOWN, condition_true_if_invalid, True
+ ),
+ ),
+ (
+ state_with_attributes(other_state, False, False)
+ for other_state in other_states
+ ),
+ ),
+ ),
+ ),
+ # Test each target state individually to isolate condition_true expectations
+ *(
+ (
+ condition,
+ condition_options,
+ [
+ state_with_attributes(other_states[0], False, False),
+ state_with_attributes(target_state, True, False),
+ ],
+ )
+ for target_state in target_states
+ ),
+ ]
+
+
+def parametrize_condition_states_any(
+ *,
+ condition: str,
+ condition_options: dict[str, Any] | None = None,
+ target_states: list[str | None | tuple[str | None, dict]],
+ other_states: list[str | None | tuple[str | None, dict]],
+ additional_attributes: dict | None = None,
+) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
+ """Parametrize states and expected condition evaluations.
+
+ The target_states and other_states iterables are either iterables of
+ states or iterables of (state, attributes) tuples.
+
+ Returns a list of tuples with (condition, condition options, list of states),
+ where states is a list of ConditionStateDescription dicts.
+ """
+
+ return _parametrize_condition_states(
+ condition=condition,
+ condition_options=condition_options,
+ target_states=target_states,
+ other_states=other_states,
+ additional_attributes=additional_attributes,
+ condition_true_if_invalid=False,
+ )
+
+
+def parametrize_condition_states_all(
+ *,
+ condition: str,
+ condition_options: dict[str, Any] | None = None,
+ target_states: list[str | None | tuple[str | None, dict]],
+ other_states: list[str | None | tuple[str | None, dict]],
+ additional_attributes: dict | None = None,
+) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
+ """Parametrize states and expected condition evaluations.
+
+ The target_states and other_states iterables are either iterables of
+ states or iterables of (state, attributes) tuples.
+
+ Returns a list of tuples with (condition, condition options, list of states),
+ where states is a list of ConditionStateDescription dicts.
+ """
+
+ return _parametrize_condition_states(
+ condition=condition,
+ condition_options=condition_options,
+ target_states=target_states,
+ other_states=other_states,
+ additional_attributes=additional_attributes,
+ condition_true_if_invalid=True,
+ )
def parametrize_trigger_states(
@@ -181,7 +358,7 @@ def parametrize_trigger_states(
additional_attributes: dict | None = None,
trigger_from_none: bool = True,
retrigger_on_target_state: bool = False,
-) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
+) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts.
The target_states and other_states iterables are either iterables of
@@ -194,7 +371,7 @@ def parametrize_trigger_states(
when the state changes to another target state.
Returns a list of tuples with (trigger, list of states),
- where states is a list of StateDescription dicts.
+ where states is a list of TriggerStateDescription dicts.
"""
additional_attributes = additional_attributes or {}
@@ -202,8 +379,8 @@ def parametrize_trigger_states(
def state_with_attributes(
state: str | None | tuple[str | None, dict], count: int
- ) -> dict:
- """Return (state, attributes) dict."""
+ ) -> TriggerStateDescription:
+ """Return TriggerStateDescription dict."""
if isinstance(state, str) or state is None:
return {
"included": {
@@ -353,7 +530,7 @@ def parametrize_trigger_states(
def parametrize_numerical_attribute_changed_trigger_states(
trigger: str, state: str, attribute: str
-) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
+) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical changed triggers."""
return [
*parametrize_trigger_states(
@@ -398,7 +575,7 @@ def parametrize_numerical_attribute_changed_trigger_states(
def parametrize_numerical_attribute_crossed_threshold_trigger_states(
trigger: str, state: str, attribute: str
-) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
+) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical crossed threshold triggers."""
return [
*parametrize_trigger_states(
@@ -500,10 +677,28 @@ async def arm_trigger(
)
+async def create_target_condition(
+ hass: HomeAssistant,
+ *,
+ condition: str,
+ target: dict,
+ behavior: str,
+) -> ConditionCheckerTypeOptional:
+ """Create a target condition."""
+ return await async_condition_from_config(
+ hass,
+ {
+ CONF_CONDITION: condition,
+ CONF_TARGET: target,
+ CONF_OPTIONS: {"behavior": behavior},
+ },
+ )
+
+
def set_or_remove_state(
hass: HomeAssistant,
entity_id: str,
- state: StateDescription,
+ state: TriggerStateDescription,
) -> None:
"""Set or remove the state of an entity."""
if state["state"] is None:
@@ -526,3 +721,37 @@ def other_states(state: StrEnum | Iterable[StrEnum]) -> list[str]:
enum_class = list(state)[0].__class__
return sorted({s.value for s in enum_class} - excluded_values)
+
+
+async def assert_condition_gated_by_labs_flag(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
+) -> None:
+ """Helper to check that a condition is gated by the labs flag."""
+
+ # Local include to avoid importing the automation component unnecessarily
+ from homeassistant.components import automation # noqa: PLC0415
+
+ await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "condition": {
+ CONF_CONDITION: condition,
+ CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
+ CONF_OPTIONS: {"behavior": "any"},
+ },
+ "action": {
+ "service": "test.automation",
+ },
+ }
+ },
+ )
+
+ assert (
+ "Unnamed automation failed to setup conditions and has been disabled: "
+ f"Condition '{condition}' requires the experimental 'New triggers and "
+ "conditions' feature to be enabled in Home Assistant Labs settings "
+ "(feature flag: 'new_triggers_conditions')"
+ ) in caplog.text
diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr
index 3ebf6fb128f..9967d21392f 100644
--- a/tests/components/acaia/snapshots/test_binary_sensor.ambr
+++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Timer running',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr
index 4caea489ef0..7e8c0d5da01 100644
--- a/tests/components/acaia/snapshots/test_button.ambr
+++ b/tests/components/acaia/snapshots/test_button.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Reset timer',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Start/stop timer',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tare',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr
index 811485a64ee..e0c7a4c1abd 100644
--- a/tests/components/acaia/snapshots/test_sensor.ambr
+++ b/tests/components/acaia/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -75,6 +76,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volume flow rate',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -131,6 +133,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Weight',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr
index 67337d4d0e4..64abcac7e74 100644
--- a/tests/components/accuweather/snapshots/test_sensor.ambr
+++ b/tests/components/accuweather/snapshots/test_sensor.ambr
@@ -28,6 +28,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality day 0',
'options': dict({
}),
'original_device_class': ,
@@ -93,6 +94,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality day 1',
'options': dict({
}),
'original_device_class': ,
@@ -158,6 +160,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality day 2',
'options': dict({
}),
'original_device_class': ,
@@ -223,6 +226,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality day 3',
'options': dict({
}),
'original_device_class': ,
@@ -288,6 +292,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality day 4',
'options': dict({
}),
'original_device_class': ,
@@ -347,6 +352,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Apparent temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -404,6 +410,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud ceiling',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -461,6 +468,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover',
'options': dict({
}),
'original_device_class': None,
@@ -512,6 +520,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover day 0',
'options': dict({
}),
'original_device_class': None,
@@ -562,6 +571,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover day 1',
'options': dict({
}),
'original_device_class': None,
@@ -612,6 +622,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover day 2',
'options': dict({
}),
'original_device_class': None,
@@ -662,6 +673,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover day 3',
'options': dict({
}),
'original_device_class': None,
@@ -712,6 +724,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover day 4',
'options': dict({
}),
'original_device_class': None,
@@ -762,6 +775,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover night 0',
'options': dict({
}),
'original_device_class': None,
@@ -812,6 +826,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover night 1',
'options': dict({
}),
'original_device_class': None,
@@ -862,6 +877,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover night 2',
'options': dict({
}),
'original_device_class': None,
@@ -912,6 +928,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover night 3',
'options': dict({
}),
'original_device_class': None,
@@ -962,6 +979,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cloud cover night 4',
'options': dict({
}),
'original_device_class': None,
@@ -1012,6 +1030,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition day 0',
'options': dict({
}),
'original_device_class': None,
@@ -1061,6 +1080,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition day 1',
'options': dict({
}),
'original_device_class': None,
@@ -1110,6 +1130,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition day 2',
'options': dict({
}),
'original_device_class': None,
@@ -1159,6 +1180,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition day 3',
'options': dict({
}),
'original_device_class': None,
@@ -1208,6 +1230,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition day 4',
'options': dict({
}),
'original_device_class': None,
@@ -1257,6 +1280,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition night 0',
'options': dict({
}),
'original_device_class': None,
@@ -1306,6 +1330,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition night 1',
'options': dict({
}),
'original_device_class': None,
@@ -1355,6 +1380,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition night 2',
'options': dict({
}),
'original_device_class': None,
@@ -1404,6 +1430,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition night 3',
'options': dict({
}),
'original_device_class': None,
@@ -1453,6 +1480,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition night 4',
'options': dict({
}),
'original_device_class': None,
@@ -1504,6 +1532,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1559,6 +1588,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Grass pollen day 0',
'options': dict({
}),
'original_device_class': None,
@@ -1610,6 +1640,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Grass pollen day 1',
'options': dict({
}),
'original_device_class': None,
@@ -1661,6 +1692,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Grass pollen day 2',
'options': dict({
}),
'original_device_class': None,
@@ -1712,6 +1744,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Grass pollen day 3',
'options': dict({
}),
'original_device_class': None,
@@ -1763,6 +1796,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Grass pollen day 4',
'options': dict({
}),
'original_device_class': None,
@@ -1814,6 +1848,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hours of sun day 0',
'options': dict({
}),
'original_device_class': None,
@@ -1864,6 +1899,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hours of sun day 1',
'options': dict({
}),
'original_device_class': None,
@@ -1914,6 +1950,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hours of sun day 2',
'options': dict({
}),
'original_device_class': None,
@@ -1964,6 +2001,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hours of sun day 3',
'options': dict({
}),
'original_device_class': None,
@@ -2014,6 +2052,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hours of sun day 4',
'options': dict({
}),
'original_device_class': None,
@@ -2066,6 +2105,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -2118,6 +2158,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mold pollen day 0',
'options': dict({
}),
'original_device_class': None,
@@ -2169,6 +2210,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mold pollen day 1',
'options': dict({
}),
'original_device_class': None,
@@ -2220,6 +2262,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mold pollen day 2',
'options': dict({
}),
'original_device_class': None,
@@ -2271,6 +2314,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mold pollen day 3',
'options': dict({
}),
'original_device_class': None,
@@ -2322,6 +2366,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mold pollen day 4',
'options': dict({
}),
'original_device_class': None,
@@ -2375,6 +2420,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Precipitation',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2433,6 +2479,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2494,6 +2541,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pressure tendency',
'options': dict({
}),
'original_device_class': ,
@@ -2549,6 +2597,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ragweed pollen day 0',
'options': dict({
}),
'original_device_class': None,
@@ -2600,6 +2649,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ragweed pollen day 1',
'options': dict({
}),
'original_device_class': None,
@@ -2651,6 +2701,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ragweed pollen day 2',
'options': dict({
}),
'original_device_class': None,
@@ -2702,6 +2753,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ragweed pollen day 3',
'options': dict({
}),
'original_device_class': None,
@@ -2753,6 +2805,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ragweed pollen day 4',
'options': dict({
}),
'original_device_class': None,
@@ -2806,6 +2859,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2861,6 +2915,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature max day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2915,6 +2970,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature max day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2969,6 +3025,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature max day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3023,6 +3080,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature max day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3077,6 +3135,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature max day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3131,6 +3190,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature min day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3185,6 +3245,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature min day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3239,6 +3300,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature min day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3293,6 +3355,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature min day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3347,6 +3410,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature min day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3403,6 +3467,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3458,6 +3523,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade max day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3512,6 +3578,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade max day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3566,6 +3633,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade max day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3620,6 +3688,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade max day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3674,6 +3743,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade max day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3728,6 +3798,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade min day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3782,6 +3853,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade min day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3836,6 +3908,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade min day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3890,6 +3963,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade min day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3944,6 +4018,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RealFeel temperature shade min day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -3998,6 +4073,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4052,6 +4128,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4106,6 +4183,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4160,6 +4238,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4214,6 +4293,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4268,6 +4348,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance night 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4322,6 +4403,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance night 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4376,6 +4458,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance night 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4430,6 +4513,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance night 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4484,6 +4568,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Solar irradiance night 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -4540,6 +4625,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -4595,6 +4681,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability day 0',
'options': dict({
}),
'original_device_class': None,
@@ -4645,6 +4732,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability day 1',
'options': dict({
}),
'original_device_class': None,
@@ -4695,6 +4783,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability day 2',
'options': dict({
}),
'original_device_class': None,
@@ -4745,6 +4834,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability day 3',
'options': dict({
}),
'original_device_class': None,
@@ -4795,6 +4885,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability day 4',
'options': dict({
}),
'original_device_class': None,
@@ -4845,6 +4936,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability night 0',
'options': dict({
}),
'original_device_class': None,
@@ -4895,6 +4987,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability night 1',
'options': dict({
}),
'original_device_class': None,
@@ -4945,6 +5038,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability night 2',
'options': dict({
}),
'original_device_class': None,
@@ -4995,6 +5089,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability night 3',
'options': dict({
}),
'original_device_class': None,
@@ -5045,6 +5140,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Thunderstorm probability night 4',
'options': dict({
}),
'original_device_class': None,
@@ -5095,6 +5191,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tree pollen day 0',
'options': dict({
}),
'original_device_class': None,
@@ -5146,6 +5243,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tree pollen day 1',
'options': dict({
}),
'original_device_class': None,
@@ -5197,6 +5295,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tree pollen day 2',
'options': dict({
}),
'original_device_class': None,
@@ -5248,6 +5347,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tree pollen day 3',
'options': dict({
}),
'original_device_class': None,
@@ -5299,6 +5399,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Tree pollen day 4',
'options': dict({
}),
'original_device_class': None,
@@ -5352,6 +5453,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index',
'options': dict({
}),
'original_device_class': None,
@@ -5404,6 +5506,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index day 0',
'options': dict({
}),
'original_device_class': None,
@@ -5455,6 +5558,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index day 1',
'options': dict({
}),
'original_device_class': None,
@@ -5506,6 +5610,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index day 2',
'options': dict({
}),
'original_device_class': None,
@@ -5557,6 +5662,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index day 3',
'options': dict({
}),
'original_device_class': None,
@@ -5608,6 +5714,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index day 4',
'options': dict({
}),
'original_device_class': None,
@@ -5661,6 +5768,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wet bulb temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -5718,6 +5826,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind chill temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -5775,6 +5884,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -5830,6 +5940,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -5885,6 +5996,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -5940,6 +6052,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -5995,6 +6108,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6050,6 +6164,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6105,6 +6220,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed night 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6160,6 +6276,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed night 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6215,6 +6332,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed night 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6270,6 +6388,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed night 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6325,6 +6444,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust speed night 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6382,6 +6502,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6437,6 +6558,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed day 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6492,6 +6614,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed day 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6547,6 +6670,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed day 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6602,6 +6726,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed day 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6657,6 +6782,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed day 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6712,6 +6838,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed night 0',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6767,6 +6894,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed night 1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6822,6 +6950,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed night 2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6877,6 +7006,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed night 3',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -6932,6 +7062,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed night 4',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr
index ae17c76511c..80e7a67c048 100644
--- a/tests/components/accuweather/snapshots/test_weather.ambr
+++ b/tests/components/accuweather/snapshots/test_weather.ambr
@@ -437,6 +437,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/actron_air/snapshots/test_switch.ambr b/tests/components/actron_air/snapshots/test_switch.ambr
index 5735835c8ea..cafeaa6b22a 100644
--- a/tests/components/actron_air/snapshots/test_switch.ambr
+++ b/tests/components/actron_air/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Away mode',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Continuous fan',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Quiet mode',
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Turbo mode',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/adax/snapshots/test_sensor.ambr b/tests/components/adax/snapshots/test_sensor.ambr
index ecdf2364301..7d449ee9d83 100644
--- a/tests/components/adax/snapshots/test_sensor.ambr
+++ b/tests/components/adax/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
@@ -81,6 +82,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -137,6 +139,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
@@ -196,6 +199,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -252,6 +256,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
@@ -311,6 +316,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -367,6 +373,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
@@ -426,6 +433,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/adguard/snapshots/test_sensor.ambr b/tests/components/adguard/snapshots/test_sensor.ambr
index df161bf7d39..dcbed0dce78 100644
--- a/tests/components/adguard/snapshots/test_sensor.ambr
+++ b/tests/components/adguard/snapshots/test_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Average processing speed',
'options': dict({
}),
'original_device_class': None,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DNS queries',
'options': dict({
}),
'original_device_class': None,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DNS queries blocked',
'options': dict({
}),
'original_device_class': None,
@@ -167,6 +170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DNS queries blocked ratio',
'options': dict({
}),
'original_device_class': None,
@@ -216,6 +220,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Parental control blocked',
'options': dict({
}),
'original_device_class': None,
@@ -265,6 +270,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rules count',
'options': dict({
}),
'original_device_class': None,
@@ -314,6 +320,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Safe browsing blocked',
'options': dict({
}),
'original_device_class': None,
@@ -363,6 +370,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Safe searches enforced',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/adguard/snapshots/test_switch.ambr b/tests/components/adguard/snapshots/test_switch.ambr
index b98165d7653..2eab04afd49 100644
--- a/tests/components/adguard/snapshots/test_switch.ambr
+++ b/tests/components/adguard/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filtering',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Parental control',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Protection',
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Query log',
'options': dict({
}),
'original_device_class': None,
@@ -212,6 +216,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Safe browsing',
'options': dict({
}),
'original_device_class': None,
@@ -260,6 +265,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Safe search',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/adguard/snapshots/test_update.ambr b/tests/components/adguard/snapshots/test_update.ambr
index fc6af1b61ee..e25ed5106aa 100644
--- a/tests/components/adguard/snapshots/test_update.ambr
+++ b/tests/components/adguard/snapshots/test_update.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airgradient/snapshots/test_button.ambr b/tests/components/airgradient/snapshots/test_button.ambr
index ca4c55230d2..d690837b861 100644
--- a/tests/components/airgradient/snapshots/test_button.ambr
+++ b/tests/components/airgradient/snapshots/test_button.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Calibrate CO2 sensor',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Test LED bar',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Calibrate CO2 sensor',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr
index 4440f4353a1..c77673680b2 100644
--- a/tests/components/airgradient/snapshots/test_number.ambr
+++ b/tests/components/airgradient/snapshots/test_number.ambr
@@ -25,6 +25,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display brightness',
'options': dict({
}),
'original_device_class': None,
@@ -83,6 +84,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'LED bar brightness',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr
index f282d27bc61..79ca2ee76eb 100644
--- a/tests/components/airgradient/snapshots/test_select.ambr
+++ b/tests/components/airgradient/snapshots/test_select.ambr
@@ -29,6 +29,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CO2 automatic baseline duration',
'options': dict({
}),
'original_device_class': None,
@@ -90,6 +91,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Configuration source',
'options': dict({
}),
'original_device_class': None,
@@ -147,6 +149,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display PM standard',
'options': dict({
}),
'original_device_class': None,
@@ -204,6 +207,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display temperature unit',
'options': dict({
}),
'original_device_class': None,
@@ -262,6 +266,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'LED bar mode',
'options': dict({
}),
'original_device_class': None,
@@ -323,6 +328,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index learning offset',
'options': dict({
}),
'original_device_class': None,
@@ -386,6 +392,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index learning offset',
'options': dict({
}),
'original_device_class': None,
@@ -450,6 +457,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CO2 automatic baseline duration',
'options': dict({
}),
'original_device_class': None,
@@ -511,6 +519,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Configuration source',
'options': dict({
}),
'original_device_class': None,
@@ -571,6 +580,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index learning offset',
'options': dict({
}),
'original_device_class': None,
@@ -634,6 +644,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index learning offset',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr
index e205e626ab8..e8f8a289aec 100644
--- a/tests/components/airgradient/snapshots/test_sensor.ambr
+++ b/tests/components/airgradient/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -73,6 +74,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide automatic baseline calibration',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -126,6 +128,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display brightness',
'options': dict({
}),
'original_device_class': None,
@@ -180,6 +183,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display PM standard',
'options': dict({
}),
'original_device_class': ,
@@ -238,6 +242,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Display temperature unit',
'options': dict({
}),
'original_device_class': ,
@@ -293,6 +298,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -344,6 +350,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'LED bar brightness',
'options': dict({
}),
'original_device_class': None,
@@ -399,6 +406,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'LED bar mode',
'options': dict({
}),
'original_device_class': ,
@@ -455,6 +463,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index',
'options': dict({
}),
'original_device_class': None,
@@ -504,6 +513,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index learning offset',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -559,6 +569,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM0.3',
'options': dict({
}),
'original_device_class': None,
@@ -611,6 +622,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM1',
'options': dict({
}),
'original_device_class': ,
@@ -664,6 +676,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM10',
'options': dict({
}),
'original_device_class': ,
@@ -717,6 +730,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -770,6 +784,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Raw NOx',
'options': dict({
}),
'original_device_class': None,
@@ -822,6 +837,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Raw PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -875,6 +891,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Raw VOC',
'options': dict({
}),
'original_device_class': None,
@@ -927,6 +944,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': ,
@@ -980,6 +998,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1036,6 +1055,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index',
'options': dict({
}),
'original_device_class': None,
@@ -1085,6 +1105,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index learning offset',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1138,6 +1159,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide automatic baseline calibration',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1193,6 +1215,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index',
'options': dict({
}),
'original_device_class': None,
@@ -1242,6 +1265,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'NOx index learning offset',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1297,6 +1321,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Raw NOx',
'options': dict({
}),
'original_device_class': None,
@@ -1349,6 +1374,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Raw VOC',
'options': dict({
}),
'original_device_class': None,
@@ -1401,6 +1427,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': ,
@@ -1454,6 +1481,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index',
'options': dict({
}),
'original_device_class': None,
@@ -1503,6 +1531,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VOC index learning offset',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
diff --git a/tests/components/airgradient/snapshots/test_switch.ambr b/tests/components/airgradient/snapshots/test_switch.ambr
index f39654d66a7..45b1d26a4b5 100644
--- a/tests/components/airgradient/snapshots/test_switch.ambr
+++ b/tests/components/airgradient/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Post data to Airgradient',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr
index cf8ccec28dd..891ed4e25ac 100644
--- a/tests/components/airgradient/snapshots/test_update.ambr
+++ b/tests/components/airgradient/snapshots/test_update.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Firmware',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr
index 8d79f8cdf0a..ea33cdcc0a3 100644
--- a/tests/components/airly/snapshots/test_sensor.ambr
+++ b/tests/components/airly/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon monoxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -78,6 +79,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Common air quality index',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -136,6 +138,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -193,6 +196,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Nitrogen dioxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -252,6 +256,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Ozone',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -311,6 +316,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -368,6 +374,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM10',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -427,6 +434,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -486,6 +494,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -543,6 +552,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sulphur dioxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -602,6 +612,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/airobot/snapshots/test_button.ambr b/tests/components/airobot/snapshots/test_button.ambr
index d378d5c6e24..14977c59c6e 100644
--- a/tests/components/airobot/snapshots/test_button.ambr
+++ b/tests/components/airobot/snapshots/test_button.ambr
@@ -1,4 +1,53 @@
# serializer version: 1
+# name: test_buttons[button.test_thermostat_recalibrate_co2_sensor-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'button',
+ 'entity_category': ,
+ 'entity_id': 'button.test_thermostat_recalibrate_co2_sensor',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Recalibrate CO2 sensor',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Recalibrate CO2 sensor',
+ 'platform': 'airobot',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'recalibrate_co2',
+ 'unique_id': 'T01A1B2C3_recalibrate_co2',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_buttons[button.test_thermostat_recalibrate_co2_sensor-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Test Thermostat Recalibrate CO2 sensor',
+ }),
+ 'context': ,
+ 'entity_id': 'button.test_thermostat_recalibrate_co2_sensor',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unknown',
+ })
+# ---
# name: test_buttons[button.test_thermostat_restart-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -20,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Restart',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airobot/snapshots/test_climate.ambr b/tests/components/airobot/snapshots/test_climate.ambr
index 4dfc2588ed5..4fa0c5611e5 100644
--- a/tests/components/airobot/snapshots/test_climate.ambr
+++ b/tests/components/airobot/snapshots/test_climate.ambr
@@ -31,6 +31,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airobot/snapshots/test_number.ambr b/tests/components/airobot/snapshots/test_number.ambr
index e98999a1563..8afa9c59ad3 100644
--- a/tests/components/airobot/snapshots/test_number.ambr
+++ b/tests/components/airobot/snapshots/test_number.ambr
@@ -25,6 +25,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hysteresis band',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airobot/snapshots/test_sensor.ambr b/tests/components/airobot/snapshots/test_sensor.ambr
index 91e56125332..860c488ed60 100644
--- a/tests/components/airobot/snapshots/test_sensor.ambr
+++ b/tests/components/airobot/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -76,6 +77,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Device uptime',
'options': dict({
}),
'original_device_class': ,
@@ -127,6 +129,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Error count',
'options': dict({
}),
'original_device_class': None,
@@ -178,6 +181,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Heating uptime',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -237,6 +241,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airobot/snapshots/test_switch.ambr b/tests/components/airobot/snapshots/test_switch.ambr
new file mode 100644
index 00000000000..6d7d816085c
--- /dev/null
+++ b/tests/components/airobot/snapshots/test_switch.ambr
@@ -0,0 +1,99 @@
+# serializer version: 1
+# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'switch',
+ 'entity_category': ,
+ 'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Actuator exercise disabled',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Actuator exercise disabled',
+ 'platform': 'airobot',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'actuator_exercise_disabled',
+ 'unique_id': 'T01A1B2C3_actuator_exercise_disabled',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Test Thermostat Actuator exercise disabled',
+ }),
+ 'context': ,
+ 'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_switches[switch.test_thermostat_child_lock-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'switch',
+ 'entity_category': ,
+ 'entity_id': 'switch.test_thermostat_child_lock',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Child lock',
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Child lock',
+ 'platform': 'airobot',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'child_lock',
+ 'unique_id': 'T01A1B2C3_child_lock',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_switches[switch.test_thermostat_child_lock-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Test Thermostat Child lock',
+ }),
+ 'context': ,
+ 'entity_id': 'switch.test_thermostat_child_lock',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py
index 60ddb2e8e96..529605836a9 100644
--- a/tests/components/airobot/test_button.py
+++ b/tests/components/airobot/test_button.py
@@ -25,7 +25,7 @@ def platforms() -> list[Platform]:
return [Platform.BUTTON]
-@pytest.mark.usefixtures("init_integration")
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_buttons(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@@ -93,3 +93,38 @@ async def test_restart_button_connection_errors(
)
mock_airobot_client.reboot_thermostat.assert_called_once()
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+async def test_recalibrate_co2_button(
+ hass: HomeAssistant,
+ mock_airobot_client: AsyncMock,
+) -> None:
+ """Test recalibrate CO2 sensor button."""
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {ATTR_ENTITY_ID: "button.test_thermostat_recalibrate_co2_sensor"},
+ blocking=True,
+ )
+
+ mock_airobot_client.recalibrate_co2_sensor.assert_called_once()
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+async def test_recalibrate_co2_button_error(
+ hass: HomeAssistant,
+ mock_airobot_client: AsyncMock,
+) -> None:
+ """Test recalibrate CO2 sensor button error handling."""
+ mock_airobot_client.recalibrate_co2_sensor.side_effect = AirobotError("Test error")
+
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ BUTTON_DOMAIN,
+ SERVICE_PRESS,
+ {ATTR_ENTITY_ID: "button.test_thermostat_recalibrate_co2_sensor"},
+ blocking=True,
+ )
+
+ mock_airobot_client.recalibrate_co2_sensor.assert_called_once()
diff --git a/tests/components/airobot/test_switch.py b/tests/components/airobot/test_switch.py
new file mode 100644
index 00000000000..1cf52202011
--- /dev/null
+++ b/tests/components/airobot/test_switch.py
@@ -0,0 +1,177 @@
+"""Tests for the Airobot switch platform."""
+
+from unittest.mock import AsyncMock
+
+from pyairobotrest.exceptions import AirobotError
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_OFF,
+ STATE_ON,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return [Platform.SWITCH]
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+async def test_switches(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test the switch entities."""
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+@pytest.mark.parametrize(
+ ("entity_id", "method_name"),
+ [
+ ("switch.test_thermostat_child_lock", "set_child_lock"),
+ (
+ "switch.test_thermostat_actuator_exercise_disabled",
+ "toggle_actuator_exercise",
+ ),
+ ],
+)
+async def test_switch_turn_on_off(
+ hass: HomeAssistant,
+ mock_airobot_client: AsyncMock,
+ entity_id: str,
+ method_name: str,
+) -> None:
+ """Test switch turn on/off functionality."""
+ state = hass.states.get(entity_id)
+ assert state
+ assert state.state == STATE_OFF
+
+ mock_method = getattr(mock_airobot_client, method_name)
+
+ # Turn on
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: entity_id},
+ blocking=True,
+ )
+ mock_method.assert_called_once_with(True)
+ mock_method.reset_mock()
+
+ # Turn off
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: entity_id},
+ blocking=True,
+ )
+ mock_method.assert_called_once_with(False)
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+async def test_switch_state_updates(
+ hass: HomeAssistant,
+ mock_airobot_client: AsyncMock,
+ mock_settings,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test that switch state updates when coordinator refreshes."""
+ # Initial state - both switches off
+ child_lock = hass.states.get("switch.test_thermostat_child_lock")
+ assert child_lock is not None
+ assert child_lock.state == STATE_OFF
+
+ actuator_disabled = hass.states.get(
+ "switch.test_thermostat_actuator_exercise_disabled"
+ )
+ assert actuator_disabled is not None
+ assert actuator_disabled.state == STATE_OFF
+
+ # Update settings to enable both
+ mock_settings.setting_flags.childlock_enabled = True
+ mock_settings.setting_flags.actuator_exercise_disabled = True
+ mock_airobot_client.get_settings.return_value = mock_settings
+
+ # Trigger coordinator update
+ await mock_config_entry.runtime_data.async_refresh()
+ await hass.async_block_till_done()
+
+ # Verify states updated
+ child_lock = hass.states.get("switch.test_thermostat_child_lock")
+ assert child_lock is not None
+ assert child_lock.state == STATE_ON
+
+ actuator_disabled = hass.states.get(
+ "switch.test_thermostat_actuator_exercise_disabled"
+ )
+ assert actuator_disabled is not None
+ assert actuator_disabled.state == STATE_ON
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+@pytest.mark.parametrize(
+ ("entity_id", "method_name", "service", "expected_key"),
+ [
+ (
+ "switch.test_thermostat_child_lock",
+ "set_child_lock",
+ SERVICE_TURN_ON,
+ "child_lock",
+ ),
+ (
+ "switch.test_thermostat_child_lock",
+ "set_child_lock",
+ SERVICE_TURN_OFF,
+ "child_lock",
+ ),
+ (
+ "switch.test_thermostat_actuator_exercise_disabled",
+ "toggle_actuator_exercise",
+ SERVICE_TURN_ON,
+ "actuator_exercise_disabled",
+ ),
+ (
+ "switch.test_thermostat_actuator_exercise_disabled",
+ "toggle_actuator_exercise",
+ SERVICE_TURN_OFF,
+ "actuator_exercise_disabled",
+ ),
+ ],
+)
+async def test_switch_error_handling(
+ hass: HomeAssistant,
+ mock_airobot_client: AsyncMock,
+ entity_id: str,
+ method_name: str,
+ service: str,
+ expected_key: str,
+) -> None:
+ """Test switch error handling for turn on/off operations."""
+ mock_method = getattr(mock_airobot_client, method_name)
+ mock_method.side_effect = AirobotError("Test error")
+
+ with pytest.raises(HomeAssistantError, match=expected_key):
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ service,
+ {ATTR_ENTITY_ID: entity_id},
+ blocking=True,
+ )
+
+ expected_value = service == SERVICE_TURN_ON
+ mock_method.assert_called_once_with(expected_value)
diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr
index 65705c7f629..e03d7e2e513 100644
--- a/tests/components/airos/snapshots/test_binary_sensor.ambr
+++ b/tests/components/airos/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DHCP client',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DHCP server',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DHCPv6 server',
'options': dict({
}),
'original_device_class': ,
@@ -167,6 +170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Port forwarding',
'options': dict({
}),
'original_device_class': None,
@@ -215,6 +219,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PPPoE link',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr
index 815b11ddc7e..1ab57de9684 100644
--- a/tests/components/airos/snapshots/test_sensor.ambr
+++ b/tests/components/airos/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Antenna gain',
'options': dict({
}),
'original_device_class': ,
@@ -75,6 +76,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CPU load',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -130,6 +132,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Download capacity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -192,6 +195,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Network role',
'options': dict({
}),
'original_device_class': ,
@@ -247,6 +251,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Throughput receive (actual)',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -306,6 +311,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Throughput transmit (actual)',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -365,6 +371,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Upload capacity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -422,6 +429,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Uptime',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -478,6 +486,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wireless distance',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -536,6 +545,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wireless frequency',
'options': dict({
}),
'original_device_class': ,
@@ -592,6 +602,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wireless mode',
'options': dict({
}),
'original_device_class': ,
@@ -650,6 +661,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wireless role',
'options': dict({
}),
'original_device_class': ,
@@ -703,6 +715,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wireless SSID',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airpatrol/snapshots/test_climate.ambr b/tests/components/airpatrol/snapshots/test_climate.ambr
index 9495481c0e8..e1257e2dd1f 100644
--- a/tests/components/airpatrol/snapshots/test_climate.ambr
+++ b/tests/components/airpatrol/snapshots/test_climate.ambr
@@ -37,6 +37,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -119,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/airpatrol/snapshots/test_sensor.ambr b/tests/components/airpatrol/snapshots/test_sensor.ambr
index 3a142cf34f6..4047d76d9c7 100644
--- a/tests/components/airpatrol/snapshots/test_sensor.ambr
+++ b/tests/components/airpatrol/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -75,6 +76,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr
index 9cc3d1bcd13..73b33684694 100644
--- a/tests/components/airthings/snapshots/test_sensor.ambr
+++ b/tests/components/airthings/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Atmospheric pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -81,6 +82,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -137,6 +139,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -193,6 +196,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -249,6 +253,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -305,6 +310,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -361,6 +367,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Radon',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -416,6 +423,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -472,6 +480,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -528,6 +537,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Atmospheric pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -587,6 +597,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -643,6 +654,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -699,6 +711,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -755,6 +768,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Illuminance',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -811,6 +825,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -867,6 +882,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -923,6 +939,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -979,6 +996,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Atmospheric pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1038,6 +1056,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1094,6 +1113,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1150,6 +1170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1206,6 +1227,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Radon',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1261,6 +1283,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1317,6 +1340,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
diff --git a/tests/components/airtouch5/snapshots/test_cover.ambr b/tests/components/airtouch5/snapshots/test_cover.ambr
index 3db5075eb0f..adfc36c915e 100644
--- a/tests/components/airtouch5/snapshots/test_cover.ambr
+++ b/tests/components/airtouch5/snapshots/test_cover.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Damper',
'options': dict({
}),
'original_device_class': ,
@@ -71,6 +72,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Damper',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr
index 491b6c6313b..0ab053108a5 100644
--- a/tests/components/airzone/snapshots/test_sensor.ambr
+++ b/tests/components/airzone/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -75,6 +76,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -131,6 +133,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -187,6 +190,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'RSSI',
'options': dict({
}),
'original_device_class': ,
@@ -240,6 +244,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -296,6 +301,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -349,6 +355,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -402,6 +409,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': None,
@@ -454,6 +462,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -510,6 +519,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -566,6 +576,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -619,6 +630,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -672,6 +684,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': None,
@@ -724,6 +737,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -780,6 +794,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -833,6 +848,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -886,6 +902,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': None,
@@ -938,6 +955,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -994,6 +1012,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -1047,6 +1066,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -1100,6 +1120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': None,
@@ -1152,6 +1173,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1208,6 +1230,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -1261,6 +1284,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py
index 343c033728a..4421822f1c8 100644
--- a/tests/components/airzone/test_select.py
+++ b/tests/components/airzone/test_select.py
@@ -2,12 +2,13 @@
from unittest.mock import patch
-from aioairzone.common import OperationMode
+from aioairzone.common import OperationMode, QAdapt
from aioairzone.const import (
API_COLD_ANGLE,
API_DATA,
API_HEAT_ANGLE,
API_MODE,
+ API_Q_ADAPT,
API_SLEEP,
API_SYSTEM_ID,
API_ZONE_ID,
@@ -17,7 +18,7 @@ import pytest
from homeassistant.components.select import ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .util import async_init_integration
@@ -27,6 +28,11 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None:
await async_init_integration(hass)
+ # Systems
+ state = hass.states.get("select.system_1_q_adapt")
+ assert state.state == "standard"
+
+ # Zones
state = hass.states.get("select.despacho_cold_angle")
assert state.state == "90deg"
@@ -95,6 +101,71 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None:
assert state.state == "off"
+async def test_airzone_select_sys_qadapt(hass: HomeAssistant) -> None:
+ """Test select system Q-Adapt."""
+
+ await async_init_integration(hass)
+
+ put_q_adapt = {
+ API_DATA: {
+ API_SYSTEM_ID: 1,
+ API_Q_ADAPT: QAdapt.SILENCE,
+ }
+ }
+
+ with pytest.raises(ServiceValidationError):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: "select.system_1_q_adapt",
+ ATTR_OPTION: "Invalid",
+ },
+ blocking=True,
+ )
+
+ with patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.put_hvac",
+ return_value=put_q_adapt,
+ ):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: "select.system_1_q_adapt",
+ ATTR_OPTION: "silence",
+ },
+ blocking=True,
+ )
+
+ state = hass.states.get("select.system_1_q_adapt")
+ assert state.state == "silence"
+
+ put_q_adapt = {
+ API_DATA: {
+ API_SYSTEM_ID: 2,
+ API_Q_ADAPT: QAdapt.SILENCE,
+ }
+ }
+
+ with (
+ patch(
+ "homeassistant.components.airzone.AirzoneLocalApi.put_hvac",
+ return_value=put_q_adapt,
+ ),
+ pytest.raises(HomeAssistantError),
+ ):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: "select.system_1_q_adapt",
+ ATTR_OPTION: "silence",
+ },
+ blocking=True,
+ )
+
+
async def test_airzone_select_sleep(hass: HomeAssistant) -> None:
"""Test select sleep."""
diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py
new file mode 100644
index 00000000000..500b1c6c290
--- /dev/null
+++ b/tests/components/alarm_control_panel/test_condition.py
@@ -0,0 +1,264 @@
+"""Test alarm_control_panel conditions."""
+
+from typing import Any
+
+import pytest
+
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanelEntityFeature,
+ AlarmControlPanelState,
+)
+from homeassistant.const import ATTR_SUPPORTED_FEATURES
+from homeassistant.core import HomeAssistant
+
+from tests.components import (
+ ConditionStateDescription,
+ assert_condition_gated_by_labs_flag,
+ create_target_condition,
+ other_states,
+ parametrize_condition_states_all,
+ parametrize_condition_states_any,
+ parametrize_target_entities,
+ set_or_remove_state,
+ target_entities,
+)
+
+
+@pytest.fixture
+async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]:
+ """Create multiple alarm_control_panel entities associated with different targets."""
+ return (await target_entities(hass, "alarm_control_panel"))["included"]
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ "alarm_control_panel.is_armed",
+ "alarm_control_panel.is_armed_away",
+ "alarm_control_panel.is_armed_home",
+ "alarm_control_panel.is_armed_night",
+ "alarm_control_panel.is_armed_vacation",
+ "alarm_control_panel.is_disarmed",
+ "alarm_control_panel.is_triggered",
+ ],
+)
+async def test_alarm_control_panel_conditions_gated_by_labs_flag(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
+) -> None:
+ """Test the alarm_control_panel conditions are gated by the labs flag."""
+ await assert_condition_gated_by_labs_flag(hass, caplog, condition)
+
+
+@pytest.mark.usefixtures("enable_labs_preview_features")
+@pytest.mark.parametrize(
+ ("condition_target_config", "entity_id", "entities_in_target"),
+ parametrize_target_entities("alarm_control_panel"),
+)
+@pytest.mark.parametrize(
+ ("condition", "condition_options", "states"),
+ [
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_armed",
+ target_states=[
+ AlarmControlPanelState.ARMED_AWAY,
+ AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
+ AlarmControlPanelState.ARMED_HOME,
+ AlarmControlPanelState.ARMED_NIGHT,
+ AlarmControlPanelState.ARMED_VACATION,
+ ],
+ other_states=[
+ AlarmControlPanelState.ARMING,
+ AlarmControlPanelState.DISARMED,
+ AlarmControlPanelState.DISARMING,
+ AlarmControlPanelState.PENDING,
+ AlarmControlPanelState.TRIGGERED,
+ ],
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_armed_away",
+ target_states=[AlarmControlPanelState.ARMED_AWAY],
+ other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
+ },
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_armed_home",
+ target_states=[AlarmControlPanelState.ARMED_HOME],
+ other_states=other_states(AlarmControlPanelState.ARMED_HOME),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
+ },
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_armed_night",
+ target_states=[AlarmControlPanelState.ARMED_NIGHT],
+ other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
+ },
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_armed_vacation",
+ target_states=[AlarmControlPanelState.ARMED_VACATION],
+ other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
+ },
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_disarmed",
+ target_states=[AlarmControlPanelState.DISARMED],
+ other_states=other_states(AlarmControlPanelState.DISARMED),
+ ),
+ *parametrize_condition_states_any(
+ condition="alarm_control_panel.is_triggered",
+ target_states=[AlarmControlPanelState.TRIGGERED],
+ other_states=other_states(AlarmControlPanelState.TRIGGERED),
+ ),
+ ],
+)
+async def test_alarm_control_panel_state_condition_behavior_any(
+ hass: HomeAssistant,
+ target_alarm_control_panels: list[str],
+ condition_target_config: dict,
+ entity_id: str,
+ entities_in_target: int,
+ condition: str,
+ condition_options: dict[str, Any],
+ states: list[ConditionStateDescription],
+) -> None:
+ """Test the alarm_control_panel state condition with the 'any' behavior."""
+ other_entity_ids = set(target_alarm_control_panels) - {entity_id}
+
+ # Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
+ for eid in target_alarm_control_panels:
+ set_or_remove_state(hass, eid, states[0]["included"])
+ await hass.async_block_till_done()
+
+ condition = await create_target_condition(
+ hass,
+ condition=condition,
+ target=condition_target_config,
+ behavior="any",
+ )
+
+ for state in states:
+ included_state = state["included"]
+ set_or_remove_state(hass, entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true"]
+
+ # Check if changing other alarm_control_panels also passes the condition
+ for other_entity_id in other_entity_ids:
+ set_or_remove_state(hass, other_entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true"]
+
+
+@pytest.mark.usefixtures("enable_labs_preview_features")
+@pytest.mark.parametrize(
+ ("condition_target_config", "entity_id", "entities_in_target"),
+ parametrize_target_entities("alarm_control_panel"),
+)
+@pytest.mark.parametrize(
+ ("condition", "condition_options", "states"),
+ [
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_armed",
+ target_states=[
+ AlarmControlPanelState.ARMED_AWAY,
+ AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
+ AlarmControlPanelState.ARMED_HOME,
+ AlarmControlPanelState.ARMED_NIGHT,
+ AlarmControlPanelState.ARMED_VACATION,
+ ],
+ other_states=[
+ AlarmControlPanelState.ARMING,
+ AlarmControlPanelState.DISARMED,
+ AlarmControlPanelState.DISARMING,
+ AlarmControlPanelState.PENDING,
+ AlarmControlPanelState.TRIGGERED,
+ ],
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_armed_away",
+ target_states=[AlarmControlPanelState.ARMED_AWAY],
+ other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
+ },
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_armed_home",
+ target_states=[AlarmControlPanelState.ARMED_HOME],
+ other_states=other_states(AlarmControlPanelState.ARMED_HOME),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
+ },
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_armed_night",
+ target_states=[AlarmControlPanelState.ARMED_NIGHT],
+ other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
+ },
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_armed_vacation",
+ target_states=[AlarmControlPanelState.ARMED_VACATION],
+ other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
+ additional_attributes={
+ ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
+ },
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_disarmed",
+ target_states=[AlarmControlPanelState.DISARMED],
+ other_states=other_states(AlarmControlPanelState.DISARMED),
+ ),
+ *parametrize_condition_states_all(
+ condition="alarm_control_panel.is_triggered",
+ target_states=[AlarmControlPanelState.TRIGGERED],
+ other_states=other_states(AlarmControlPanelState.TRIGGERED),
+ ),
+ ],
+)
+async def test_alarm_control_panel_state_condition_behavior_all(
+ hass: HomeAssistant,
+ target_alarm_control_panels: list[str],
+ condition_target_config: dict,
+ entity_id: str,
+ entities_in_target: int,
+ condition: str,
+ condition_options: dict[str, Any],
+ states: list[ConditionStateDescription],
+) -> None:
+ """Test the alarm_control_panel state condition with the 'all' behavior."""
+ other_entity_ids = set(target_alarm_control_panels) - {entity_id}
+
+ # Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
+ for eid in target_alarm_control_panels:
+ set_or_remove_state(hass, eid, states[0]["included"])
+ await hass.async_block_till_done()
+
+ condition = await create_target_condition(
+ hass,
+ condition=condition,
+ target=condition_target_config,
+ behavior="all",
+ )
+
+ for state in states:
+ included_state = state["included"]
+
+ set_or_remove_state(hass, entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true_first_entity"]
+
+ for other_entity_id in other_entity_ids:
+ set_or_remove_state(hass, other_entity_id, included_state)
+ await hass.async_block_till_done()
+
+ assert condition(hass) == state["condition_true"]
diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py
index d52ee5733a1..0774353f5f3 100644
--- a/tests/components/alarm_control_panel/test_device_action.py
+++ b/tests/components/alarm_control_panel/test_device_action.py
@@ -25,11 +25,6 @@ from tests.common import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
@pytest.mark.parametrize(
("set_state", "features_reg", "features_state", "expected_action_types"),
[
diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py
index 37cbc466e6d..9d098a9b30b 100644
--- a/tests/components/alarm_control_panel/test_device_condition.py
+++ b/tests/components/alarm_control_panel/test_device_condition.py
@@ -18,11 +18,6 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_get_device_automations
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
@pytest.mark.parametrize(
("set_state", "features_reg", "features_state", "expected_condition_types"),
[
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index 979bc33bb00..d5ad857fd1b 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -26,11 +26,6 @@ from tests.common import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
@pytest.mark.parametrize(
("set_state", "features_reg", "features_state", "expected_trigger_types"),
[
diff --git a/tests/components/alarm_control_panel/test_trigger.py b/tests/components/alarm_control_panel/test_trigger.py
index ff04f0110a7..f6c67beea92 100644
--- a/tests/components/alarm_control_panel/test_trigger.py
+++ b/tests/components/alarm_control_panel/test_trigger.py
@@ -1,8 +1,6 @@
"""Test alarm control panel triggers."""
-from collections.abc import Generator
from typing import Any
-from unittest.mock import patch
import pytest
@@ -14,7 +12,7 @@ from homeassistant.const import ATTR_LABEL_ID, ATTR_SUPPORTED_FEATURES, CONF_ENT
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
- StateDescription,
+ TriggerStateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
@@ -24,21 +22,6 @@ from tests.components import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
-@pytest.fixture(name="enable_experimental_triggers_conditions")
-def enable_experimental_triggers_conditions() -> Generator[None]:
- """Enable experimental triggers and conditions."""
- with patch(
- "homeassistant.components.labs.async_is_preview_feature_enabled",
- return_value=True,
- ):
- yield
-
-
@pytest.fixture
async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]:
"""Create multiple alarm control panel entities associated with different targets."""
@@ -70,7 +53,7 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag(
) in caplog.text
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
@@ -152,7 +135,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the alarm control panel state trigger fires when any alarm control panel state changes to a specific state."""
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
@@ -181,7 +164,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
service_calls.clear()
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
@@ -263,7 +246,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the alarm control panel state trigger fires when the first alarm control panel changes to a specific state."""
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
@@ -291,7 +274,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
assert len(service_calls) == 0
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
@@ -373,7 +356,7 @@ async def test_alarm_control_panel_state_trigger_behavior_last(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the alarm_control_panel state trigger fires when the last alarm_control_panel changes to a specific state."""
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
diff --git a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
index c6b9a2afa08..874e0d5b35c 100644
--- a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
+++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Connectivity',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/alexa_devices/snapshots/test_notify.ambr b/tests/components/alexa_devices/snapshots/test_notify.ambr
index 64776c14420..5dd78166ce9 100644
--- a/tests/components/alexa_devices/snapshots/test_notify.ambr
+++ b/tests/components/alexa_devices/snapshots/test_notify.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Announce',
'options': dict({
}),
'original_device_class': None,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Speak',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/alexa_devices/snapshots/test_sensor.ambr b/tests/components/alexa_devices/snapshots/test_sensor.ambr
index 38565a46879..916fd36c038 100644
--- a/tests/components/alexa_devices/snapshots/test_sensor.ambr
+++ b/tests/components/alexa_devices/snapshots/test_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Next alarm',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Next reminder',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Next timer',
'options': dict({
}),
'original_device_class': ,
@@ -169,6 +172,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/alexa_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr
index 3ce484cf95b..14343e21302 100644
--- a/tests/components/alexa_devices/snapshots/test_switch.ambr
+++ b/tests/components/alexa_devices/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Do not disturb',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/altruist/snapshots/test_sensor.ambr b/tests/components/altruist/snapshots/test_sensor.ambr
index 9340e10cbe8..be65db990f6 100644
--- a/tests/components/altruist/snapshots/test_sensor.ambr
+++ b/tests/components/altruist/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Average noise',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -78,6 +79,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'BME280 humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -134,6 +136,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'BME280 pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -193,6 +196,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'BME280 temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -249,6 +253,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Maximum noise',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -305,6 +310,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM10',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -361,6 +367,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -417,6 +424,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Radiation level',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -472,6 +480,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr
index 6d88a142c34..78ef107d479 100644
--- a/tests/components/ambient_network/snapshots/test_sensor.ambr
+++ b/tests/components/ambient_network/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Absolute pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -83,6 +84,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Daily rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -144,6 +146,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -202,6 +205,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Feels like',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -260,6 +264,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hourly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -321,6 +326,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -379,6 +385,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Irradiance',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -435,6 +442,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last rain',
'options': dict({
}),
'original_device_class': ,
@@ -488,6 +496,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Max daily gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -549,6 +558,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Monthly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -610,6 +620,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Relative pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -671,6 +682,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -729,6 +741,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -786,6 +799,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Weekly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -847,6 +861,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind direction',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -905,6 +920,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -966,6 +982,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1027,6 +1044,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Absolute pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1088,6 +1106,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Daily rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1149,6 +1168,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1207,6 +1227,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Feels like',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1265,6 +1286,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hourly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1326,6 +1348,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1384,6 +1407,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Irradiance',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1440,6 +1464,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last rain',
'options': dict({
}),
'original_device_class': ,
@@ -1493,6 +1518,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Max daily gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1554,6 +1580,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Monthly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1615,6 +1642,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Relative pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1676,6 +1704,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1734,6 +1763,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1791,6 +1821,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Weekly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1852,6 +1883,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind direction',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1910,6 +1942,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1971,6 +2004,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2032,6 +2066,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Absolute pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2092,6 +2127,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Daily rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2152,6 +2188,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2209,6 +2246,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Feels like',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2266,6 +2304,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hourly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2326,6 +2365,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2383,6 +2423,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Irradiance',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2440,6 +2481,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Max daily gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2500,6 +2542,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Monthly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2560,6 +2603,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Relative pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2620,6 +2664,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2677,6 +2722,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'UV index',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2733,6 +2779,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Weekly rain',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2793,6 +2840,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind direction',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2850,6 +2898,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind gust',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2910,6 +2959,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wind speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py
index f2789b6f479..0dfac987603 100644
--- a/tests/components/analytics/test_analytics.py
+++ b/tests/components/analytics/test_analytics.py
@@ -1201,6 +1201,7 @@ async def test_devices_payload_with_entities(
entity_category=EntityCategory.CONFIG,
has_entity_name=True,
original_device_class=NumberDeviceClass.TEMPERATURE,
+ suggested_object_id="hue_1",
)
hass.states.async_set("number.hue_1", "2")
# Entity with assumed state
@@ -1210,6 +1211,7 @@ async def test_devices_payload_with_entities(
unique_id="2",
device_id=device_entry.id,
has_entity_name=True,
+ suggested_object_id="hue_2",
)
hass.states.async_set("light.hue_2", "on", {ATTR_ASSUMED_STATE: True})
# Entity from a different integration
diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr
index 4b71e2fef3e..7c85d24c049 100644
--- a/tests/components/analytics_insights/snapshots/test_sensor.ambr
+++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'core_samba',
'options': dict({
}),
'original_device_class': None,
@@ -74,6 +75,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'hacs (custom)',
'options': dict({
}),
'original_device_class': None,
@@ -126,6 +128,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'myq',
'options': dict({
}),
'original_device_class': None,
@@ -178,6 +181,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'spotify',
'options': dict({
}),
'original_device_class': None,
@@ -230,6 +234,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total active installations',
'options': dict({
}),
'original_device_class': None,
@@ -282,6 +287,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total reported integrations',
'options': dict({
}),
'original_device_class': None,
@@ -334,6 +340,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'YouTube',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/anglian_water/snapshots/test_sensor.ambr b/tests/components/anglian_water/snapshots/test_sensor.ambr
index d63bd9296d2..377509fb2e6 100644
--- a/tests/components/anglian_water/snapshots/test_sensor.ambr
+++ b/tests/components/anglian_water/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Latest reading',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -76,6 +77,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': "Yesterday's sewerage cost",
'options': dict({
}),
'original_device_class': ,
@@ -128,6 +130,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': "Yesterday's usage",
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -182,6 +185,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': "Yesterday's water cost",
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/aosmith/snapshots/test_select.ambr b/tests/components/aosmith/snapshots/test_select.ambr
index 9e0c10319c3..8f2dec51fa0 100644
--- a/tests/components/aosmith/snapshots/test_select.ambr
+++ b/tests/components/aosmith/snapshots/test_select.ambr
@@ -27,6 +27,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'hot_water_plus_level',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr
index ae0752ee1ed..b613557882b 100644
--- a/tests/components/aosmith/snapshots/test_sensor.ambr
+++ b/tests/components/aosmith/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy usage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -76,6 +77,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Hot water availability',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr
index 452b2a05e2e..f94c9d32128 100644
--- a/tests/components/aosmith/snapshots/test_water_heater.ambr
+++ b/tests/components/aosmith/snapshots/test_water_heater.ambr
@@ -23,6 +23,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -87,6 +88,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py
index 27ddd478b9b..8d53a4c8639 100644
--- a/tests/components/apcupsd/__init__.py
+++ b/tests/components/apcupsd/__init__.py
@@ -10,7 +10,7 @@ CONF_DATA: Final = {CONF_HOST: "test", CONF_PORT: 1234}
MOCK_STATUS: Final = {
"APC": "001,038,0985",
- "DATE": "1970-01-01 00:00:00 0000",
+ "DATE": "1970-01-01 00:00:00 +0000",
"VERSION": "3.14.14 (31 May 2016) unknown",
"CABLE": "USB Cable",
"DRIVER": "USB UPS Driver",
@@ -19,6 +19,7 @@ MOCK_STATUS: Final = {
"APCMODEL": "Back-UPS ES 600",
"MODEL": "Back-UPS ES 600",
"STATUS": "ONLINE",
+ "STARTTIME": "2006-01-01 00:00:00 +0500",
"LINEV": "124.0 Volts",
"LOADPCT": "14.0 Percent",
"BCHARGE": "100.0 Percent",
@@ -36,11 +37,11 @@ MOCK_STATUS: Final = {
"OUTCURNT": "0.88 Amps",
"LASTXFER": "Automatic or explicit self test",
"NUMXFERS": "1",
- "XONBATT": "1970-01-01 00:00:00 0000",
+ "XONBATT": "1970-01-01 00:00:00 +0000",
"TONBATT": "0 Seconds",
"CUMONBATT": "8 Seconds",
- "XOFFBATT": "1970-01-01 00:00:00 0000",
- "LASTSTEST": "1970-01-01 00:00:00 0000",
+ "XOFFBATT": "1970-01-01 00:00:00 +0000",
+ "LASTSTEST": "1970-01-01 00:00:00 +0000",
"SELFTEST": "NO",
"STESTI": "7 days",
"STATFLAG": "0x05000008",
@@ -50,7 +51,8 @@ MOCK_STATUS: Final = {
"NOMBATTV": "12.0 Volts",
"NOMPOWER": "330 Watts",
"FIRMWARE": "928.a8 .D USB FW:a8",
- "END APC": "1970-01-01 00:00:00 0000",
+ "MASTERUPD": "1970-01-01 00:00:00 +0000",
+ "END APC": "1970-01-01 00:00:00 +0000",
}
# Minimal status adapted from http://www.apcupsd.org/manual/manual.html#apcaccess-test.
@@ -58,13 +60,13 @@ MOCK_STATUS: Final = {
# of the integration to handle such cases.
MOCK_MINIMAL_STATUS: Final = {
"APC": "001,012,0319",
- "DATE": "1970-01-01 00:00:00 0000",
+ "DATE": "1970-01-01 00:00:00 +0000",
"RELEASE": "3.8.5",
"CABLE": "APC Cable 940-0128A",
"UPSMODE": "Stand Alone",
- "STARTTIME": "1970-01-01 00:00:00 0000",
+ "STARTTIME": "1970-01-01 00:00:00 +0000",
"LINEFAIL": "OK",
"BATTSTAT": "OK",
"STATFLAG": "0x008",
- "END APC": "1970-01-01 00:00:00 0000",
+ "END APC": "1970-01-01 00:00:00 +0000",
}
diff --git a/tests/components/apcupsd/snapshots/test_binary_sensor.ambr b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr
index 898525cde9c..28c12a16731 100644
--- a/tests/components/apcupsd/snapshots/test_binary_sensor.ambr
+++ b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Online status',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/apcupsd/snapshots/test_diagnostics.ambr b/tests/components/apcupsd/snapshots/test_diagnostics.ambr
index 669654c75bb..e7c31dd91bd 100644
--- a/tests/components/apcupsd/snapshots/test_diagnostics.ambr
+++ b/tests/components/apcupsd/snapshots/test_diagnostics.ambr
@@ -9,17 +9,18 @@
'BCHARGE': '100.0 Percent',
'CABLE': 'USB Cable',
'CUMONBATT': '8 Seconds',
- 'DATE': '1970-01-01 00:00:00 0000',
+ 'DATE': '1970-01-01 00:00:00 +0000',
'DRIVER': 'USB UPS Driver',
- 'END APC': '1970-01-01 00:00:00 0000',
+ 'END APC': '1970-01-01 00:00:00 +0000',
'FIRMWARE': '928.a8 .D USB FW:a8',
'HITRANS': '139.0 Volts',
'ITEMP': '34.6 C Internal',
- 'LASTSTEST': '1970-01-01 00:00:00 0000',
+ 'LASTSTEST': '1970-01-01 00:00:00 +0000',
'LASTXFER': 'Automatic or explicit self test',
'LINEV': '124.0 Volts',
'LOADPCT': '14.0 Percent',
'LOTRANS': '92.0 Volts',
+ 'MASTERUPD': '1970-01-01 00:00:00 +0000',
'MAXTIME': '0 Seconds',
'MBATTCHG': '5 Percent',
'MINTIMEL': '3 Minutes',
@@ -33,6 +34,7 @@
'SELFTEST': 'NO',
'SENSE': 'Medium',
'SERIALNO': '**REDACTED**',
+ 'STARTTIME': '2006-01-01 00:00:00 +0500',
'STATFLAG': '0x05000008',
'STATUS': 'ONLINE',
'STESTI': '7 days',
@@ -41,7 +43,7 @@
'UPSMODE': 'Stand Alone',
'UPSNAME': 'MyUPS',
'VERSION': '3.14.14 (31 May 2016) unknown',
- 'XOFFBATT': '1970-01-01 00:00:00 0000',
- 'XONBATT': '1970-01-01 00:00:00 0000',
+ 'XOFFBATT': '1970-01-01 00:00:00 +0000',
+ 'XONBATT': '1970-01-01 00:00:00 +0000',
})
# ---
diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr
index 73b349650b8..37d87c395bf 100644
--- a/tests/components/apcupsd/snapshots/test_sensor.ambr
+++ b/tests/components/apcupsd/snapshots/test_sensor.ambr
@@ -260,6 +260,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Alarm delay',
'options': dict({
}),
'original_device_class': None,
@@ -311,6 +312,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -362,6 +364,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery nominal voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -415,6 +418,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery replaced',
'options': dict({
}),
'original_device_class': None,
@@ -463,6 +467,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery shutdown',
'options': dict({
}),
'original_device_class': None,
@@ -512,6 +517,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery timeout',
'options': dict({
}),
'original_device_class': None,
@@ -563,6 +569,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -617,6 +624,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Cable type',
'options': dict({
}),
'original_device_class': None,
@@ -665,6 +673,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Daemon version',
'options': dict({
}),
'original_device_class': None,
@@ -713,6 +722,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Date and time',
'options': dict({
}),
'original_device_class': None,
@@ -737,7 +747,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1970-01-01 00:00:00 0000',
+ 'state': '1970-01-01 00:00:00 +0000',
})
# ---
# name: test_sensor[sensor.myups_driver-entry]
@@ -761,6 +771,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Driver',
'options': dict({
}),
'original_device_class': None,
@@ -809,6 +820,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Firmware version',
'options': dict({
}),
'original_device_class': None,
@@ -859,6 +871,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Input voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -915,6 +928,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Internal temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -969,9 +983,10 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last self-test',
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Last self-test',
'platform': 'apcupsd',
@@ -986,6 +1001,7 @@
# name: test_sensor[sensor.myups_last_self_test-state]
StateSnapshot({
'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
'friendly_name': 'MyUPS Last self-test',
}),
'context': ,
@@ -993,7 +1009,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1970-01-01 00:00:00 0000',
+ 'state': '1970-01-01T00:00:00+00:00',
})
# ---
# name: test_sensor[sensor.myups_last_transfer-entry]
@@ -1017,6 +1033,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last transfer',
'options': dict({
}),
'original_device_class': None,
@@ -1067,6 +1084,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Load',
'options': dict({
}),
'original_device_class': None,
@@ -1096,6 +1114,56 @@
'state': '14.0',
})
# ---
+# name: test_sensor[sensor.myups_master_update-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.myups_master_update',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Master update',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Master update',
+ 'platform': 'apcupsd',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'master_update',
+ 'unique_id': 'XXXXXXXXXXXX_masterupd',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensor[sensor.myups_master_update-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
+ 'friendly_name': 'MyUPS Master update',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.myups_master_update',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1970-01-01T00:00:00+00:00',
+ })
+# ---
# name: test_sensor[sensor.myups_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1117,6 +1185,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mode',
'options': dict({
}),
'original_device_class': None,
@@ -1165,6 +1234,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Model',
'options': dict({
}),
'original_device_class': None,
@@ -1213,6 +1283,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Model',
'options': dict({
}),
'original_device_class': None,
@@ -1261,6 +1332,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Name',
'options': dict({
}),
'original_device_class': None,
@@ -1309,6 +1381,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Nominal apparent power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1362,6 +1435,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Nominal input voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1415,6 +1489,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Nominal output power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1470,6 +1545,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Output current',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1524,6 +1600,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Self-test interval',
'options': dict({
}),
'original_device_class': None,
@@ -1573,6 +1650,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Self-test result',
'options': dict({
}),
'original_device_class': None,
@@ -1621,6 +1699,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sensitivity',
'options': dict({
}),
'original_device_class': None,
@@ -1669,6 +1748,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Serial number',
'options': dict({
}),
'original_device_class': None,
@@ -1717,6 +1797,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Shutdown time',
'options': dict({
}),
'original_device_class': None,
@@ -1745,6 +1826,56 @@
'state': '3',
})
# ---
+# name: test_sensor[sensor.myups_startup_time-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.myups_startup_time',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'object_id_base': 'Startup time',
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Startup time',
+ 'platform': 'apcupsd',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'startup_time',
+ 'unique_id': 'XXXXXXXXXXXX_starttime',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensor[sensor.myups_startup_time-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
+ 'friendly_name': 'MyUPS Startup time',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.myups_startup_time',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2005-12-31T19:00:00+00:00',
+ })
+# ---
# name: test_sensor[sensor.myups_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1766,6 +1897,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': None,
@@ -1814,6 +1946,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Status data',
'options': dict({
}),
'original_device_class': None,
@@ -1862,6 +1995,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Status date',
'options': dict({
}),
'original_device_class': None,
@@ -1886,7 +2020,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1970-01-01 00:00:00 0000',
+ 'state': '1970-01-01 00:00:00 +0000',
})
# ---
# name: test_sensor[sensor.myups_status_flag-entry]
@@ -1910,6 +2044,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Status flag',
'options': dict({
}),
'original_device_class': None,
@@ -1960,6 +2095,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Time left',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2016,6 +2152,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Time on battery',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2072,6 +2209,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total time on battery',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2128,6 +2266,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Transfer count',
'options': dict({
}),
'original_device_class': None,
@@ -2177,9 +2316,10 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Transfer from battery',
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Transfer from battery',
'platform': 'apcupsd',
@@ -2194,6 +2334,7 @@
# name: test_sensor[sensor.myups_transfer_from_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
'friendly_name': 'MyUPS Transfer from battery',
}),
'context': ,
@@ -2201,7 +2342,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1970-01-01 00:00:00 0000',
+ 'state': '1970-01-01T00:00:00+00:00',
})
# ---
# name: test_sensor[sensor.myups_transfer_high-entry]
@@ -2225,6 +2366,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Transfer high',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2278,6 +2420,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Transfer low',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2331,9 +2474,10 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Transfer to battery',
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Transfer to battery',
'platform': 'apcupsd',
@@ -2348,6 +2492,7 @@
# name: test_sensor[sensor.myups_transfer_to_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
'friendly_name': 'MyUPS Transfer to battery',
}),
'context': ,
@@ -2355,6 +2500,6 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '1970-01-01 00:00:00 0000',
+ 'state': '1970-01-01T00:00:00+00:00',
})
# ---
diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py
index 2842bdf1258..2da69b89531 100644
--- a/tests/components/apcupsd/test_sensor.py
+++ b/tests/components/apcupsd/test_sensor.py
@@ -3,6 +3,7 @@
from datetime import timedelta
from unittest.mock import AsyncMock
+import dateutil.parser
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -151,13 +152,17 @@ async def test_sensor_unknown(
# Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of
# the sensor should be properly updated with the corresponding value.
+ last_self_test_value = "1970-01-01 00:00:00 +0000"
mock_request_status.return_value = MOCK_MINIMAL_STATUS | {
- "LASTSTEST": "1970-01-01 00:00:00 0000"
+ "LASTSTEST": last_self_test_value
}
future = utcnow() + timedelta(minutes=2)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
- assert hass.states.get(last_self_test_id).state == "1970-01-01 00:00:00 0000"
+ assert (
+ hass.states.get(last_self_test_id).state
+ == dateutil.parser.parse(last_self_test_value).isoformat()
+ )
# Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported.
mock_request_status.return_value = MOCK_MINIMAL_STATUS
diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr
index d8088288461..c17df2691f8 100644
--- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr
+++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DC 1 short circuit error status',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DC 2 short circuit error status',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Off-grid status',
'options': dict({
}),
'original_device_class': ,
@@ -167,6 +170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Output fault status',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr
index 7d02e6e16c4..871e236d7d2 100644
--- a/tests/components/apsystems/snapshots/test_number.ambr
+++ b/tests/components/apsystems/snapshots/test_number.ambr
@@ -25,6 +25,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Max output',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/apsystems/snapshots/test_sensor.ambr b/tests/components/apsystems/snapshots/test_sensor.ambr
index f163c4db840..01470a917a7 100644
--- a/tests/components/apsystems/snapshots/test_sensor.ambr
+++ b/tests/components/apsystems/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lifetime production of P1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -78,6 +79,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lifetime production of P2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -134,6 +136,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Power of P1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -190,6 +193,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Power of P2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -246,6 +250,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Production of today',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -302,6 +307,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Production of today from P1',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -358,6 +364,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Production of today from P2',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -414,6 +421,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total lifetime production',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -470,6 +478,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
diff --git a/tests/components/apsystems/snapshots/test_switch.ambr b/tests/components/apsystems/snapshots/test_switch.ambr
index 2b3ccbab6c4..ba6b1295cc3 100644
--- a/tests/components/apsystems/snapshots/test_switch.ambr
+++ b/tests/components/apsystems/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Inverter status',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr
index c24a7f43cfe..198fe481c4b 100644
--- a/tests/components/aquacell/snapshots/test_sensor.ambr
+++ b/tests/components/aquacell/snapshots/test_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -70,6 +71,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last update',
'options': dict({
}),
'original_device_class': ,
@@ -121,6 +123,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Salt left side percentage',
'options': dict({
}),
'original_device_class': None,
@@ -171,6 +174,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Salt left side time remaining',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -226,6 +230,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Salt right side percentage',
'options': dict({
}),
'original_device_class': None,
@@ -276,6 +281,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Salt right side time remaining',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -335,6 +341,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Wi-Fi strength',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py
index eb5cf1d7892..9e97d253711 100644
--- a/tests/components/arcam_fmj/test_device_trigger.py
+++ b/tests/components/arcam_fmj/test_device_trigger.py
@@ -1,7 +1,5 @@
"""The tests for Arcam FMJ Receiver control device triggers."""
-import pytest
-
from homeassistant.components import automation
from homeassistant.components.arcam_fmj.const import DOMAIN
from homeassistant.components.device_automation import DeviceAutomationType
@@ -12,11 +10,6 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_get_device_automations
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
async def test_get_triggers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr
index 18643ac1755..d386d0d234e 100644
--- a/tests/components/arve/snapshots/test_sensor.ambr
+++ b/tests/components/arve/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Air quality index',
'options': dict({
}),
'original_device_class': ,
@@ -59,6 +60,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -96,6 +98,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -133,6 +136,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM10',
'options': dict({
}),
'original_device_class': ,
@@ -170,6 +174,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -207,6 +212,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -247,6 +253,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Total volatile organic compounds',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py
new file mode 100644
index 00000000000..2615a69fb4f
--- /dev/null
+++ b/tests/components/assist_satellite/test_condition.py
@@ -0,0 +1,179 @@
+"""Test assist satellite conditions."""
+
+from typing import Any
+
+import pytest
+
+from homeassistant.components.assist_satellite.entity import AssistSatelliteState
+from homeassistant.core import HomeAssistant
+
+from tests.components import (
+ ConditionStateDescription,
+ assert_condition_gated_by_labs_flag,
+ create_target_condition,
+ other_states,
+ parametrize_condition_states_all,
+ parametrize_condition_states_any,
+ parametrize_target_entities,
+ set_or_remove_state,
+ target_entities,
+)
+
+
+@pytest.fixture
+async def target_assist_satellites(hass: HomeAssistant) -> list[str]:
+ """Create multiple assist satellite entities associated with different targets."""
+ return (await target_entities(hass, "assist_satellite"))["included"]
+
+
+@pytest.mark.parametrize(
+ "condition",
+ [
+ "assist_satellite.is_idle",
+ "assist_satellite.is_listening",
+ "assist_satellite.is_processing",
+ "assist_satellite.is_responding",
+ ],
+)
+async def test_assist_satellite_conditions_gated_by_labs_flag(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
+) -> None:
+ """Test the assist satellite conditions are gated by the labs flag."""
+ await assert_condition_gated_by_labs_flag(hass, caplog, condition)
+
+
+@pytest.mark.usefixtures("enable_labs_preview_features")
+@pytest.mark.parametrize(
+ ("condition_target_config", "entity_id", "entities_in_target"),
+ parametrize_target_entities("assist_satellite"),
+)
+@pytest.mark.parametrize(
+ ("condition", "condition_options", "states"),
+ [
+ *parametrize_condition_states_any(
+ condition="assist_satellite.is_idle",
+ target_states=[AssistSatelliteState.IDLE],
+ other_states=other_states(AssistSatelliteState.IDLE),
+ ),
+ *parametrize_condition_states_any(
+ condition="assist_satellite.is_listening",
+ target_states=[AssistSatelliteState.LISTENING],
+ other_states=other_states(AssistSatelliteState.LISTENING),
+ ),
+ *parametrize_condition_states_any(
+ condition="assist_satellite.is_processing",
+ target_states=[AssistSatelliteState.PROCESSING],
+ other_states=other_states(AssistSatelliteState.PROCESSING),
+ ),
+ *parametrize_condition_states_any(
+ condition="assist_satellite.is_responding",
+ target_states=[AssistSatelliteState.RESPONDING],
+ other_states=other_states(AssistSatelliteState.RESPONDING),
+ ),
+ ],
+)
+async def test_assist_satellite_state_condition_behavior_any(
+ hass: HomeAssistant,
+ target_assist_satellites: list[str],
+ condition_target_config: dict,
+ entity_id: str,
+ entities_in_target: int,
+ condition: str,
+ condition_options: dict[str, Any],
+ states: list[ConditionStateDescription],
+) -> None:
+ """Test the assist satellite state condition with the 'any' behavior."""
+ other_entity_ids = set(target_assist_satellites) - {entity_id}
+
+ # Set all assist satellites, including the tested one, to the initial state
+ for eid in target_assist_satellites:
+ set_or_remove_state(hass, eid, states[0]["included"])
+ await hass.async_block_till_done()
+
+ condition = await create_target_condition(
+ hass,
+ condition=condition,
+ target=condition_target_config,
+ behavior="any",
+ )
+
+ for state in states:
+ included_state = state["included"]
+ set_or_remove_state(hass, entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true"]
+
+ # Check if changing other assist satellites also passes the condition
+ for other_entity_id in other_entity_ids:
+ set_or_remove_state(hass, other_entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true"]
+
+
+@pytest.mark.usefixtures("enable_labs_preview_features")
+@pytest.mark.parametrize(
+ ("condition_target_config", "entity_id", "entities_in_target"),
+ parametrize_target_entities("assist_satellite"),
+)
+@pytest.mark.parametrize(
+ ("condition", "condition_options", "states"),
+ [
+ *parametrize_condition_states_all(
+ condition="assist_satellite.is_idle",
+ target_states=[AssistSatelliteState.IDLE],
+ other_states=other_states(AssistSatelliteState.IDLE),
+ ),
+ *parametrize_condition_states_all(
+ condition="assist_satellite.is_listening",
+ target_states=[AssistSatelliteState.LISTENING],
+ other_states=other_states(AssistSatelliteState.LISTENING),
+ ),
+ *parametrize_condition_states_all(
+ condition="assist_satellite.is_processing",
+ target_states=[AssistSatelliteState.PROCESSING],
+ other_states=other_states(AssistSatelliteState.PROCESSING),
+ ),
+ *parametrize_condition_states_all(
+ condition="assist_satellite.is_responding",
+ target_states=[AssistSatelliteState.RESPONDING],
+ other_states=other_states(AssistSatelliteState.RESPONDING),
+ ),
+ ],
+)
+async def test_assist_satellite_state_condition_behavior_all(
+ hass: HomeAssistant,
+ target_assist_satellites: list[str],
+ condition_target_config: dict,
+ entity_id: str,
+ entities_in_target: int,
+ condition: str,
+ condition_options: dict[str, Any],
+ states: list[ConditionStateDescription],
+) -> None:
+ """Test the assist satellite state condition with the 'all' behavior."""
+ other_entity_ids = set(target_assist_satellites) - {entity_id}
+
+ # Set all assist satellites, including the tested one, to the initial state
+ for eid in target_assist_satellites:
+ set_or_remove_state(hass, eid, states[0]["included"])
+ await hass.async_block_till_done()
+
+ condition = await create_target_condition(
+ hass,
+ condition=condition,
+ target=condition_target_config,
+ behavior="all",
+ )
+
+ for state in states:
+ included_state = state["included"]
+
+ set_or_remove_state(hass, entity_id, included_state)
+ await hass.async_block_till_done()
+ assert condition(hass) == state["condition_true_first_entity"]
+
+ for other_entity_id in other_entity_ids:
+ set_or_remove_state(hass, other_entity_id, included_state)
+ await hass.async_block_till_done()
+
+ assert condition(hass) == state["condition_true"]
diff --git a/tests/components/assist_satellite/test_trigger.py b/tests/components/assist_satellite/test_trigger.py
index 8111d8f96e7..5b49d614c75 100644
--- a/tests/components/assist_satellite/test_trigger.py
+++ b/tests/components/assist_satellite/test_trigger.py
@@ -1,8 +1,6 @@
"""Test assist satellite triggers."""
-from collections.abc import Generator
from typing import Any
-from unittest.mock import patch
import pytest
@@ -11,7 +9,7 @@ from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
- StateDescription,
+ TriggerStateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
@@ -21,21 +19,6 @@ from tests.components import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
-@pytest.fixture(name="enable_experimental_triggers_conditions")
-def enable_experimental_triggers_conditions() -> Generator[None]:
- """Enable experimental triggers and conditions."""
- with patch(
- "homeassistant.components.labs.async_is_preview_feature_enabled",
- return_value=True,
- ):
- yield
-
-
@pytest.fixture
async def target_assist_satellites(hass: HomeAssistant) -> list[str]:
"""Create multiple assist satellite entities associated with different targets."""
@@ -64,7 +47,7 @@ async def test_assist_satellite_triggers_gated_by_labs_flag(
) in caplog.text
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
@@ -103,7 +86,7 @@ async def test_assist_satellite_state_trigger_behavior_any(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the assist satellite state trigger fires when any assist satellite state changes to a specific state."""
other_entity_ids = set(target_assist_satellites) - {entity_id}
@@ -132,7 +115,7 @@ async def test_assist_satellite_state_trigger_behavior_any(
service_calls.clear()
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
@@ -171,7 +154,7 @@ async def test_assist_satellite_state_trigger_behavior_first(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the assist satellite state trigger fires when the first assist satellite changes to a specific state."""
other_entity_ids = set(target_assist_satellites) - {entity_id}
@@ -199,7 +182,7 @@ async def test_assist_satellite_state_trigger_behavior_first(
assert len(service_calls) == 0
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
@@ -238,7 +221,7 @@ async def test_assist_satellite_state_trigger_behavior_last(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the assist_satellite state trigger fires when the last assist_satellite changes to a specific state."""
other_entity_ids = set(target_assist_satellites) - {entity_id}
diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr
index 73a07d71656..32c410c8f0a 100644
--- a/tests/components/autarco/snapshots/test_sensor.ambr
+++ b/tests/components/autarco/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charged month',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -78,6 +79,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charged today',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -134,6 +136,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charged total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -190,6 +193,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Discharged month',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -246,6 +250,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Discharged today',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -302,6 +307,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Discharged total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -358,6 +364,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Flow now',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -414,6 +421,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'State of charge',
'options': dict({
}),
'original_device_class': ,
@@ -467,6 +475,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy AC output total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -523,6 +532,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Power AC output',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -579,6 +589,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy AC output total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -635,6 +646,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Power AC output',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -691,6 +703,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy production month',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -747,6 +760,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy production today',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -803,6 +817,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Energy production total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -859,6 +874,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Power production',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
diff --git a/tests/components/automation/conftest.py b/tests/components/automation/conftest.py
deleted file mode 100644
index 1d2d39a463d..00000000000
--- a/tests/components/automation/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Conftest for automation tests."""
-
-import pytest
-
-
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 6bacee92f5e..0eb618ed633 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -2232,7 +2232,7 @@ async def test_extraction_functions(
assert automation.blueprint_in_automation(hass, "automation.test3") is None
-async def test_extraction_functions_with_targets(
+async def test_extraction_functions_with_trigger_targets(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
hass_ws_client: WebSocketGenerator,
@@ -2428,6 +2428,211 @@ async def test_extraction_functions_with_targets(
}
+async def test_extraction_functions_with_condition_targets(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test extraction functions with targets in conditions."""
+ config_entry = MockConfigEntry(domain="fake_integration", data={})
+ config_entry.mock_state(hass, ConfigEntryState.LOADED)
+ config_entry.add_to_hass(hass)
+
+ condition_device = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")},
+ )
+
+ await async_setup_component(hass, "homeassistant", {})
+ await async_setup_component(hass, "light", {"light": {"platform": "demo"}})
+ await hass.async_block_till_done()
+
+ # Enable the new_triggers_conditions feature flag to allow new-style conditions
+ assert await async_setup_component(hass, "labs", {})
+ ws_client = await hass_ws_client(hass)
+ await ws_client.send_json_auto_id(
+ {
+ "type": "labs/update",
+ "domain": "automation",
+ "preview_feature": "new_triggers_conditions",
+ "enabled": True,
+ }
+ )
+ msg = await ws_client.receive_json()
+ assert msg["success"]
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: [
+ {
+ "alias": "test1",
+ "triggers": [
+ {"trigger": "state", "entity_id": "sensor.trigger_state"},
+ ],
+ "conditions": [
+ # Single entity_id in target
+ {
+ "condition": "light.is_on",
+ "target": {"entity_id": "light.condition_entity"},
+ "options": {"behavior": "any"},
+ },
+ # Multiple entity_ids in target
+ {
+ "condition": "light.is_on",
+ "target": {
+ "entity_id": [
+ "light.condition_entity_list1",
+ "light.condition_entity_list2",
+ ]
+ },
+ "options": {"behavior": "any"},
+ },
+ # Single device_id in target
+ {
+ "condition": "light.is_on",
+ "target": {"device_id": condition_device.id},
+ "options": {"behavior": "any"},
+ },
+ # Multiple device_ids in target
+ {
+ "condition": "light.is_on",
+ "target": {
+ "device_id": [
+ "target-device-1",
+ "target-device-2",
+ ]
+ },
+ "options": {"behavior": "any"},
+ },
+ # Single area_id in target
+ {
+ "condition": "light.is_on",
+ "target": {"area_id": "area-condition-single"},
+ "options": {"behavior": "any"},
+ },
+ # Multiple area_ids in target
+ {
+ "condition": "light.is_on",
+ "target": {
+ "area_id": ["area-condition-1", "area-condition-2"]
+ },
+ "options": {"behavior": "any"},
+ },
+ # Single floor_id in target
+ {
+ "condition": "light.is_on",
+ "target": {"floor_id": "floor-condition-single"},
+ "options": {"behavior": "any"},
+ },
+ # Multiple floor_ids in target
+ {
+ "condition": "light.is_on",
+ "target": {
+ "floor_id": ["floor-condition-1", "floor-condition-2"]
+ },
+ "options": {"behavior": "any"},
+ },
+ # Single label_id in target
+ {
+ "condition": "light.is_on",
+ "target": {"label_id": "label-condition-single"},
+ "options": {"behavior": "any"},
+ },
+ # Multiple label_ids in target
+ {
+ "condition": "light.is_on",
+ "target": {
+ "label_id": ["label-condition-1", "label-condition-2"]
+ },
+ "options": {"behavior": "any"},
+ },
+ # Combined targets
+ {
+ "condition": "light.is_on",
+ "target": {
+ "entity_id": "light.combined_entity",
+ "device_id": "combined-device",
+ "area_id": "combined-area",
+ "floor_id": "combined-floor",
+ "label_id": "combined-label",
+ },
+ "options": {"behavior": "any"},
+ },
+ ],
+ "actions": [
+ {
+ "action": "test.script",
+ "data": {"entity_id": "light.action_entity"},
+ },
+ ],
+ },
+ ]
+ },
+ )
+
+ # Test entity extraction from condition targets
+ assert set(automation.entities_in_automation(hass, "automation.test1")) == {
+ "sensor.trigger_state",
+ "light.condition_entity",
+ "light.condition_entity_list1",
+ "light.condition_entity_list2",
+ "light.combined_entity",
+ "light.action_entity",
+ }
+
+ # Test device extraction from condition targets
+ assert set(automation.devices_in_automation(hass, "automation.test1")) == {
+ condition_device.id,
+ "target-device-1",
+ "target-device-2",
+ "combined-device",
+ }
+
+ # Test area extraction from condition targets
+ assert set(automation.areas_in_automation(hass, "automation.test1")) == {
+ "area-condition-single",
+ "area-condition-1",
+ "area-condition-2",
+ "combined-area",
+ }
+
+ # Test floor extraction from condition targets
+ assert set(automation.floors_in_automation(hass, "automation.test1")) == {
+ "floor-condition-single",
+ "floor-condition-1",
+ "floor-condition-2",
+ "combined-floor",
+ }
+
+ # Test label extraction from condition targets
+ assert set(automation.labels_in_automation(hass, "automation.test1")) == {
+ "label-condition-single",
+ "label-condition-1",
+ "label-condition-2",
+ "combined-label",
+ }
+
+ # Test automations_with_* functions
+ assert set(automation.automations_with_entity(hass, "light.condition_entity")) == {
+ "automation.test1"
+ }
+ assert set(automation.automations_with_device(hass, condition_device.id)) == {
+ "automation.test1"
+ }
+ assert set(automation.automations_with_area(hass, "area-condition-single")) == {
+ "automation.test1"
+ }
+ assert set(automation.automations_with_floor(hass, "floor-condition-single")) == {
+ "automation.test1"
+ }
+ assert set(automation.automations_with_label(hass, "label-condition-single")) == {
+ "automation.test1"
+ }
+
+
async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None:
"""Test humanifying Automation Trigger event."""
hass.config.components.add("recorder")
diff --git a/tests/components/awair/snapshots/test_sensor.ambr b/tests/components/awair/snapshots/test_sensor.ambr
index 2b1981eb27e..815bde609fb 100644
--- a/tests/components/awair/snapshots/test_sensor.ambr
+++ b/tests/components/awair/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -77,6 +78,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -132,6 +134,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM10',
'options': dict({
}),
'original_device_class': ,
@@ -187,6 +190,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -242,6 +246,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -295,6 +300,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -353,6 +359,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
@@ -408,6 +415,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -463,6 +471,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -518,6 +527,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -573,6 +583,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -626,6 +637,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -684,6 +696,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
@@ -739,6 +752,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -794,6 +808,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -849,6 +864,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -902,6 +918,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -960,6 +977,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
@@ -1015,6 +1033,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -1070,6 +1089,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Illuminance',
'options': dict({
}),
'original_device_class': ,
@@ -1124,6 +1144,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -1179,6 +1200,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -1232,6 +1254,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1290,6 +1313,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
@@ -1345,6 +1369,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -1400,6 +1425,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -1455,6 +1481,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Illuminance',
'options': dict({
}),
'original_device_class': ,
@@ -1509,6 +1536,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -1564,6 +1592,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -1617,6 +1646,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound level',
'options': dict({
}),
'original_device_class': ,
@@ -1671,6 +1701,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1729,6 +1760,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
@@ -1784,6 +1816,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Absolute humidity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1841,6 +1874,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': ,
@@ -1895,6 +1929,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -1952,6 +1987,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -2006,6 +2042,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': ,
@@ -2060,6 +2097,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Score',
'options': dict({
}),
'original_device_class': None,
@@ -2113,6 +2151,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -2170,6 +2209,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/axis/snapshots/test_binary_sensor.ambr b/tests/components/axis/snapshots/test_binary_sensor.ambr
index fb762800c12..6dc76f5e09d 100644
--- a/tests/components/axis/snapshots/test_binary_sensor.ambr
+++ b/tests/components/axis/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'DayNight 1',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Object Analytics Device1Scenario8',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound 1',
'options': dict({
}),
'original_device_class': ,
@@ -167,6 +170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PIR sensor',
'options': dict({
}),
'original_device_class': ,
@@ -216,6 +220,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'PIR 0',
'options': dict({
}),
'original_device_class': ,
@@ -265,6 +270,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Fence Guard Profile 1',
'options': dict({
}),
'original_device_class': ,
@@ -314,6 +320,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Motion Guard Profile 1',
'options': dict({
}),
'original_device_class': ,
@@ -363,6 +370,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Loitering Guard Profile 1',
'options': dict({
}),
'original_device_class': ,
@@ -412,6 +420,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VMD4 Profile 1',
'options': dict({
}),
'original_device_class': ,
@@ -461,6 +470,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Object Analytics Scenario 1',
'options': dict({
}),
'original_device_class': ,
@@ -510,6 +520,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'VMD4 Camera1Profile9',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr
index 68b9cd07e53..f6416190278 100644
--- a/tests/components/axis/snapshots/test_camera.ambr
+++ b/tests/components/axis/snapshots/test_camera.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -71,6 +72,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/axis/snapshots/test_light.ambr b/tests/components/axis/snapshots/test_light.ambr
index aec750ecda3..db8a35141da 100644
--- a/tests/components/axis/snapshots/test_light.ambr
+++ b/tests/components/axis/snapshots/test_light.ambr
@@ -24,6 +24,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'IR Light 0',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/axis/snapshots/test_switch.ambr b/tests/components/axis/snapshots/test_switch.ambr
index 1e9a2d0b068..181184a7892 100644
--- a/tests/components/axis/snapshots/test_switch.ambr
+++ b/tests/components/axis/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Doorbell',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Relay 1',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Doorbell',
'options': dict({
}),
'original_device_class': ,
@@ -167,6 +170,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Relay 1',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr
index 865cd79ee1f..9bdaa2573a5 100644
--- a/tests/components/azure_devops/snapshots/test_sensor.ambr
+++ b/tests/components/azure_devops/snapshots/test_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build',
'options': dict({
}),
'original_device_class': None,
@@ -80,6 +81,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build finish time',
'options': dict({
}),
'original_device_class': ,
@@ -129,6 +131,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build ID',
'options': dict({
}),
'original_device_class': None,
@@ -177,6 +180,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build queue time',
'options': dict({
}),
'original_device_class': ,
@@ -226,6 +230,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build reason',
'options': dict({
}),
'original_device_class': None,
@@ -274,6 +279,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build result',
'options': dict({
}),
'original_device_class': None,
@@ -322,6 +328,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build source branch',
'options': dict({
}),
'original_device_class': None,
@@ -370,6 +377,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build source version',
'options': dict({
}),
'original_device_class': None,
@@ -418,6 +426,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build start time',
'options': dict({
}),
'original_device_class': ,
@@ -467,6 +476,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CI latest build URL',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/backup/snapshots/test_event.ambr b/tests/components/backup/snapshots/test_event.ambr
index 78f60bf8d20..a5393e93128 100644
--- a/tests/components/backup/snapshots/test_event.ambr
+++ b/tests/components/backup/snapshots/test_event.ambr
@@ -26,6 +26,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Automatic backup',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr
index 034ca91239b..4348d19b385 100644
--- a/tests/components/backup/snapshots/test_sensors.ambr
+++ b/tests/components/backup/snapshots/test_sensors.ambr
@@ -28,6 +28,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Backup Manager state',
'options': dict({
}),
'original_device_class': ,
@@ -84,6 +85,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last attempted automatic backup',
'options': dict({
}),
'original_device_class': ,
@@ -133,6 +135,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Last successful automatic backup',
'options': dict({
}),
'original_device_class': ,
@@ -182,6 +185,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Next scheduled automatic backup',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/balboa/snapshots/test_binary_sensor.ambr b/tests/components/balboa/snapshots/test_binary_sensor.ambr
index 51f1dfa8e3f..1e9b1e046f9 100644
--- a/tests/components/balboa/snapshots/test_binary_sensor.ambr
+++ b/tests/components/balboa/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Circulation pump',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 1',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 2',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/balboa/snapshots/test_climate.ambr b/tests/components/balboa/snapshots/test_climate.ambr
index b616c77de7d..bad83ab7b42 100644
--- a/tests/components/balboa/snapshots/test_climate.ambr
+++ b/tests/components/balboa/snapshots/test_climate.ambr
@@ -31,6 +31,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr
index 2a9b5540101..48f1d07e86f 100644
--- a/tests/components/balboa/snapshots/test_event.ambr
+++ b/tests/components/balboa/snapshots/test_event.ambr
@@ -41,6 +41,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Fault',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_fan.ambr b/tests/components/balboa/snapshots/test_fan.ambr
index e4d619dc536..34a48e79872 100644
--- a/tests/components/balboa/snapshots/test_fan.ambr
+++ b/tests/components/balboa/snapshots/test_fan.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pump 1',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_light.ambr b/tests/components/balboa/snapshots/test_light.ambr
index af4b4f973e7..02bdfd8457f 100644
--- a/tests/components/balboa/snapshots/test_light.ambr
+++ b/tests/components/balboa/snapshots/test_light.ambr
@@ -24,6 +24,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Light',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_select.ambr b/tests/components/balboa/snapshots/test_select.ambr
index ae0aafa449e..ceee8096185 100644
--- a/tests/components/balboa/snapshots/test_select.ambr
+++ b/tests/components/balboa/snapshots/test_select.ambr
@@ -25,6 +25,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature range',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr
index 886e07f64bf..f5002cdffe3 100644
--- a/tests/components/balboa/snapshots/test_switch.ambr
+++ b/tests/components/balboa/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 2 enabled',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr
index 2d1f9c42e95..75d9fd92355 100644
--- a/tests/components/balboa/snapshots/test_time.ambr
+++ b/tests/components/balboa/snapshots/test_time.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 1 end',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 1 start',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 2 end',
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Filter cycle 2 start',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py
index 254c5428806..1e11e64d542 100644
--- a/tests/components/binary_sensor/test_device_condition.py
+++ b/tests/components/binary_sensor/test_device_condition.py
@@ -26,11 +26,6 @@ from tests.common import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
async def test_get_conditions(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py
index e9ad5d0a1e1..dfbf106dc91 100644
--- a/tests/components/binary_sensor/test_device_trigger.py
+++ b/tests/components/binary_sensor/test_device_trigger.py
@@ -26,11 +26,6 @@ from tests.common import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
async def test_get_triggers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/binary_sensor/test_trigger.py b/tests/components/binary_sensor/test_trigger.py
index 8c08feb17b6..94a48557c7d 100644
--- a/tests/components/binary_sensor/test_trigger.py
+++ b/tests/components/binary_sensor/test_trigger.py
@@ -1,8 +1,6 @@
"""Test binary sensor trigger."""
-from collections.abc import Generator
from typing import Any
-from unittest.mock import patch
import pytest
@@ -16,7 +14,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
- StateDescription,
+ TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
@@ -25,21 +23,6 @@ from tests.components import (
)
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
-
-
-@pytest.fixture(name="enable_experimental_triggers_conditions")
-def enable_experimental_triggers_conditions() -> Generator[None]:
- """Enable experimental triggers and conditions."""
- with patch(
- "homeassistant.components.labs.async_is_preview_feature_enabled",
- return_value=True,
- ):
- yield
-
-
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> tuple[list[str], list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
@@ -66,7 +49,7 @@ async def test_binary_sensor_triggers_gated_by_labs_flag(
) in caplog.text
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
@@ -99,7 +82,7 @@ async def test_binary_sensor_state_attribute_trigger_behavior_any(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when any binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
@@ -136,7 +119,7 @@ async def test_binary_sensor_state_attribute_trigger_behavior_any(
service_calls.clear()
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
@@ -169,7 +152,7 @@ async def test_binary_sensor_state_attribute_trigger_behavior_first(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when the first binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
@@ -205,7 +188,7 @@ async def test_binary_sensor_state_attribute_trigger_behavior_first(
assert len(service_calls) == 0
-@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
+@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
@@ -238,7 +221,7 @@ async def test_binary_sensor_state_attribute_trigger_behavior_last(
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
- states: list[StateDescription],
+ states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when the last binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
diff --git a/tests/components/blue_current/snapshots/test_button.ambr b/tests/components/blue_current/snapshots/test_button.ambr
index 36a043630ea..8e447492342 100644
--- a/tests/components/blue_current/snapshots/test_button.ambr
+++ b/tests/components/blue_current/snapshots/test_button.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Reboot',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Reset',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Stop charge session',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bluemaestro/snapshots/test_sensor.ambr b/tests/components/bluemaestro/snapshots/test_sensor.ambr
index 055ceb2731f..a081de9c6a6 100644
--- a/tests/components/bluemaestro/snapshots/test_sensor.ambr
+++ b/tests/components/bluemaestro/snapshots/test_sensor.ambr
@@ -22,6 +22,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -75,6 +76,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
@@ -131,6 +133,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': ,
@@ -184,6 +187,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': ,
@@ -237,6 +241,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
diff --git a/tests/components/blueprint/common.py b/tests/components/blueprint/common.py
deleted file mode 100644
index 037aa38f6cb..00000000000
--- a/tests/components/blueprint/common.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Blueprints test helpers."""
-
-from collections.abc import Generator
-from unittest.mock import patch
-
-
-def stub_blueprint_populate_fixture_helper() -> Generator[None]:
- """Stub copying the blueprints to the config folder."""
- with patch(
- "homeassistant.components.blueprint.models.DomainBlueprints.async_populate"
- ):
- yield
diff --git a/tests/components/blueprint/conftest.py b/tests/components/blueprint/conftest.py
deleted file mode 100644
index 21cc17ef1ba..00000000000
--- a/tests/components/blueprint/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Blueprints conftest."""
-
-import pytest
-
-
-@pytest.fixture(autouse=True, name="stub_blueprint_populate")
-def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
- """Stub copying the blueprints to the config folder."""
diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py
index 8374054ca95..96a9323fda5 100644
--- a/tests/components/blueprint/test_websocket_api.py
+++ b/tests/components/blueprint/test_websocket_api.py
@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml
+from tests.common import MockUser
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@@ -103,6 +104,51 @@ async def test_list_blueprints_non_existing_domain(
assert blueprints == {}
+@pytest.mark.parametrize(
+ "message",
+ [
+ {"type": "blueprint/list", "domain": "automation"},
+ {"type": "blueprint/import", "url": "https://example.com/blueprint.yaml"},
+ {
+ "type": "blueprint/save",
+ "path": "test_save",
+ "yaml": "raw_data",
+ "domain": "automation",
+ },
+ {
+ "type": "blueprint/delete",
+ "path": "test_delete",
+ "domain": "automation",
+ },
+ {
+ "type": "blueprint/substitute",
+ "domain": "automation",
+ "path": "test_event_service.yaml",
+ "input": {
+ "trigger_event": "test_event",
+ "service_to_call": "test.automation",
+ "a_number": 5,
+ },
+ },
+ ],
+)
+async def test_blueprint_ws_command_requires_admin(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ hass_admin_user: MockUser,
+ message: dict[str, Any],
+) -> None:
+ """Test that blueprint websocket commands require admin."""
+ hass_admin_user.groups = [] # Remove admin privileges
+ client = await hass_ws_client(hass)
+ await client.send_json_auto_id(message)
+
+ msg = await client.receive_json()
+
+ assert not msg["success"]
+ assert msg["error"]["code"] == "unauthorized"
+
+
async def test_import_blueprint(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr
index 3a7cdd86be1..7290a7c7c98 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -69,6 +70,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Check control messages',
'options': dict({
}),
'original_device_class': ,
@@ -118,6 +120,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition-based services',
'options': dict({
}),
'original_device_class': ,
@@ -173,6 +176,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Connection status',
'options': dict({
}),
'original_device_class': ,
@@ -222,6 +226,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Door lock state',
'options': dict({
}),
'original_device_class': ,
@@ -272,6 +277,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lids',
'options': dict({
}),
'original_device_class': ,
@@ -328,6 +334,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pre-entry climatization',
'options': dict({
}),
'original_device_class': None,
@@ -376,6 +383,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Windows',
'options': dict({
}),
'original_device_class': ,
@@ -427,6 +435,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -476,6 +485,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Check control messages',
'options': dict({
}),
'original_device_class': ,
@@ -526,6 +536,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition-based services',
'options': dict({
}),
'original_device_class': ,
@@ -586,6 +597,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Connection status',
'options': dict({
}),
'original_device_class': ,
@@ -635,6 +647,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Door lock state',
'options': dict({
}),
'original_device_class': ,
@@ -685,6 +698,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lids',
'options': dict({
}),
'original_device_class': ,
@@ -740,6 +754,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pre-entry climatization',
'options': dict({
}),
'original_device_class': None,
@@ -788,6 +803,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Windows',
'options': dict({
}),
'original_device_class': ,
@@ -842,6 +858,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -891,6 +908,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Check control messages',
'options': dict({
}),
'original_device_class': ,
@@ -941,6 +959,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition-based services',
'options': dict({
}),
'original_device_class': ,
@@ -1001,6 +1020,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Connection status',
'options': dict({
}),
'original_device_class': ,
@@ -1050,6 +1070,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Door lock state',
'options': dict({
}),
'original_device_class': ,
@@ -1100,6 +1121,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lids',
'options': dict({
}),
'original_device_class': ,
@@ -1156,6 +1178,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Pre-entry climatization',
'options': dict({
}),
'original_device_class': None,
@@ -1204,6 +1227,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Windows',
'options': dict({
}),
'original_device_class': ,
@@ -1258,6 +1282,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Check control messages',
'options': dict({
}),
'original_device_class': ,
@@ -1309,6 +1334,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Condition-based services',
'options': dict({
}),
'original_device_class': ,
@@ -1372,6 +1398,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Door lock state',
'options': dict({
}),
'original_device_class': ,
@@ -1422,6 +1449,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lids',
'options': dict({
}),
'original_device_class': ,
@@ -1477,6 +1505,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Windows',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr
index f8946f8c668..4955dd6ed8e 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Activate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Find vehicle',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Flash lights',
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound horn',
'options': dict({
}),
'original_device_class': None,
@@ -212,6 +216,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Activate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -260,6 +265,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Deactivate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -308,6 +314,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Find vehicle',
'options': dict({
}),
'original_device_class': None,
@@ -356,6 +363,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Flash lights',
'options': dict({
}),
'original_device_class': None,
@@ -404,6 +412,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound horn',
'options': dict({
}),
'original_device_class': None,
@@ -452,6 +461,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Activate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -500,6 +510,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Deactivate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -548,6 +559,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Find vehicle',
'options': dict({
}),
'original_device_class': None,
@@ -596,6 +608,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Flash lights',
'options': dict({
}),
'original_device_class': None,
@@ -644,6 +657,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound horn',
'options': dict({
}),
'original_device_class': None,
@@ -692,6 +706,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Activate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -740,6 +755,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Deactivate air conditioning',
'options': dict({
}),
'original_device_class': None,
@@ -788,6 +804,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Find vehicle',
'options': dict({
}),
'original_device_class': None,
@@ -836,6 +853,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Flash lights',
'options': dict({
}),
'original_device_class': None,
@@ -884,6 +902,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Sound horn',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr
index 47eee9fdb15..72c6fb570cb 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lock',
'options': dict({
}),
'original_device_class': None,
@@ -70,6 +71,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lock',
'options': dict({
}),
'original_device_class': None,
@@ -120,6 +122,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lock',
'options': dict({
}),
'original_device_class': None,
@@ -170,6 +173,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Lock',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr
index c86ed54197c..6a00c737599 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr
@@ -25,6 +25,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Target SoC',
'options': dict({
}),
'original_device_class': ,
@@ -83,6 +84,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Target SoC',
'options': dict({
}),
'original_device_class': ,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr
index 15334fc72b8..e3282b9599d 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr
@@ -26,6 +26,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging mode',
'options': dict({
}),
'original_device_class': None,
@@ -95,6 +96,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC charging limit',
'options': dict({
}),
'original_device_class': None,
@@ -165,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging mode',
'options': dict({
}),
'original_device_class': None,
@@ -234,6 +237,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC charging limit',
'options': dict({
}),
'original_device_class': None,
@@ -304,6 +308,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging mode',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr
index 2f7d2847ad6..d54fe87c4fd 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC current limit',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -73,6 +74,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging end time',
'options': dict({
}),
'original_device_class': ,
@@ -122,6 +124,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging start time',
'options': dict({
}),
'original_device_class': ,
@@ -186,6 +189,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -249,6 +253,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging target',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -303,6 +308,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mileage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -359,6 +365,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining battery percent',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -415,6 +422,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining fuel',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -471,6 +479,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining fuel percent',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -526,6 +535,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range electric',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -582,6 +592,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range fuel',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -638,6 +649,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -692,6 +704,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC current limit',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -745,6 +758,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging end time',
'options': dict({
}),
'original_device_class': ,
@@ -794,6 +808,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging start time',
'options': dict({
}),
'original_device_class': ,
@@ -858,6 +873,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -921,6 +937,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging target',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -981,6 +998,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate status',
'options': dict({
}),
'original_device_class': ,
@@ -1039,6 +1057,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1098,6 +1117,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1157,6 +1177,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1216,6 +1237,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1275,6 +1297,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mileage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1331,6 +1354,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1390,6 +1414,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1449,6 +1474,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1508,6 +1534,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -1567,6 +1594,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining battery percent',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1623,6 +1651,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range electric',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1679,6 +1708,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1733,6 +1763,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC current limit',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -1786,6 +1817,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging end time',
'options': dict({
}),
'original_device_class': ,
@@ -1835,6 +1867,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging start time',
'options': dict({
}),
'original_device_class': ,
@@ -1899,6 +1932,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging status',
'options': dict({
}),
'original_device_class': ,
@@ -1962,6 +1996,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging target',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2022,6 +2057,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate status',
'options': dict({
}),
'original_device_class': ,
@@ -2080,6 +2116,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2139,6 +2176,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2198,6 +2236,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2257,6 +2296,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2316,6 +2356,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mileage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2372,6 +2413,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2431,6 +2473,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2490,6 +2533,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2549,6 +2593,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2608,6 +2653,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining battery percent',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2664,6 +2710,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range electric',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2720,6 +2767,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -2782,6 +2830,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate status',
'options': dict({
}),
'original_device_class': ,
@@ -2840,6 +2889,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2899,6 +2949,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -2958,6 +3009,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3017,6 +3069,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Front right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3076,6 +3129,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Mileage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -3132,6 +3186,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3191,6 +3246,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear left tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3250,6 +3306,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right target pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3309,6 +3366,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Rear right tire pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -3368,6 +3426,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining fuel',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -3424,6 +3483,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining fuel percent',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -3479,6 +3539,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range fuel',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
@@ -3535,6 +3596,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Remaining range total',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr
index afd52e82d90..cafae0a3913 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate',
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Climate',
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr
index 54e012d72c3..d0ac464a2e2 100644
--- a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr
+++ b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -72,6 +73,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -124,6 +126,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr
index 2646a6affa7..a4330d58fb4 100644
--- a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr
+++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr
@@ -20,6 +20,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Area ready to arm away',
'options': dict({
}),
'original_device_class': None,
@@ -68,6 +69,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Area ready to arm home',
'options': dict({
}),
'original_device_class': None,
@@ -116,6 +118,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -164,6 +167,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC failure',
'options': dict({
}),
'original_device_class': ,
@@ -213,6 +217,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -262,6 +267,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery missing',
'options': dict({
}),
'original_device_class': ,
@@ -311,6 +317,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CRC failure in panel configuration',
'options': dict({
}),
'original_device_class': ,
@@ -360,6 +367,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Failure to call RPS since last RPS connection',
'options': dict({
}),
'original_device_class': None,
@@ -408,6 +416,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Log overflow',
'options': dict({
}),
'original_device_class': ,
@@ -457,6 +466,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Log threshold reached',
'options': dict({
}),
'original_device_class': ,
@@ -506,6 +516,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Phone line failure',
'options': dict({
}),
'original_device_class': ,
@@ -555,6 +566,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Point bus failure since last RPS connection',
'options': dict({
}),
'original_device_class': ,
@@ -604,6 +616,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Problem',
'options': dict({
}),
'original_device_class': ,
@@ -653,6 +666,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'SDI failure since last RPS connection',
'options': dict({
}),
'original_device_class': ,
@@ -702,6 +716,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'User code tamper since last RPS connection',
'options': dict({
}),
'original_device_class': ,
@@ -751,6 +766,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -799,6 +815,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -847,6 +864,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -895,6 +913,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -943,6 +962,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -991,6 +1011,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -1039,6 +1060,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Area ready to arm away',
'options': dict({
}),
'original_device_class': None,
@@ -1087,6 +1109,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Area ready to arm home',
'options': dict({
}),
'original_device_class': None,
@@ -1135,6 +1158,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
@@ -1183,6 +1207,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'AC failure',
'options': dict({
}),
'original_device_class': ,
@@ -1232,6 +1257,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': ,
@@ -1281,6 +1307,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Battery missing',
'options': dict({
}),
'original_device_class': ,
@@ -1330,6 +1357,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'CRC failure in panel configuration',
'options': dict({
}),
'original_device_class': ,
@@ -1379,6 +1407,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Failure to call RPS since last RPS connection',
'options': dict({
}),
'original_device_class': None,
@@ -1427,6 +1456,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Log overflow',
'options': dict({
}),
'original_device_class': ,
@@ -1476,6 +1506,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Log threshold reached',
'options': dict({
}),
'original_device_class': ,
@@ -1525,6 +1556,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Phone line failure',
'options': dict({
}),
'original_device_class': ,
@@ -1574,6 +1606,7 @@
'labels': set({
}),
'name': None,
+ 'object_id_base': 'Point bus failure since last RPS connection',
'options': dict({
}),
'original_device_class':