diff --git a/.claude/agents/quality-scale-rule-verifier.md b/.claude/agents/quality-scale-rule-verifier.md
new file mode 100644
index 00000000000..a86566dea68
--- /dev/null
+++ b/.claude/agents/quality-scale-rule-verifier.md
@@ -0,0 +1,77 @@
+---
+name: quality-scale-rule-verifier
+description: |
+ Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
+
+
+ Context: The user wants to verify if an integration follows a specific quality scale rule.
+ user: "Check if the peblar integration follows the config-flow rule"
+ assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
+
+ Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
+
+
+
+
+ Context: The user is reviewing if an integration reaches a specific quality scale level.
+ user: "Verify that this integration reaches the bronze quality scale"
+ assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
+
+ The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
+
+
+model: inherit
+color: yellow
+tools: Read, Bash, Grep, Glob, WebFetch
+---
+
+You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
+
+You will verify if an integration follows a specific quality scale rule by:
+
+1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
+ `https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
+ where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
+
+2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
+ - Core requirements and mandatory implementations
+ - Specific code patterns or configurations required
+ - Common violations and anti-patterns
+ - Exemption criteria (when a rule might not apply)
+ - The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
+
+3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/` focusing on:
+ - `manifest.json` for quality scale declaration and configuration
+ - `quality_scale.yaml` for rule status (done, todo, exempt)
+ - Relevant Python modules based on the rule requirements
+ - Configuration files and service definitions as needed
+
+4. **Verification Process**:
+ - Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
+ - If marked 'exempt', verify the exemption reason is valid
+ - If marked 'done', verify the actual implementation matches requirements
+ - Identify specific files and code sections that demonstrate compliance or violations
+ - Consider the integration's declared quality tier when applying rules
+ - To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/.markdown`
+ - To fetch information about a PyPI package, use the URL `https://pypi.org/pypi//json`
+
+5. **Reporting Findings**: Provide a comprehensive verification report that includes:
+ - **Rule Summary**: Brief description of what the rule requires
+ - **Compliance Status**: Clear pass/fail/exempt determination
+ - **Evidence**: Specific code examples showing compliance or violations
+ - **Issues Found**: Detailed list of any non-compliance issues with file locations
+ - **Recommendations**: Actionable steps to achieve compliance if needed
+ - **Exemption Analysis**: If applicable, whether the exemption is justified
+
+When examining code, you will:
+- Look for exact implementation patterns specified in the rule
+- Verify all required components are present and properly configured
+- Check for common mistakes and anti-patterns
+- Consider edge cases and error handling requirements
+- Validate that implementations follow Home Assistant conventions
+
+You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
+
+If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
+
+Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.
diff --git a/.core_files.yaml b/.core_files.yaml
index 2624c4432be..5c6537aa236 100644
--- a/.core_files.yaml
+++ b/.core_files.yaml
@@ -58,6 +58,7 @@ base_platforms: &base_platforms
# Extra components that trigger the full suite
components: &components
- homeassistant/components/alexa/**
+ - homeassistant/components/analytics/**
- homeassistant/components/application_credentials/**
- homeassistant/components/assist_pipeline/**
- homeassistant/components/auth/**
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 085aa9c2b01..eabe0cd500a 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -8,6 +8,8 @@
"PYTHONASYNCIODEBUG": "1"
},
"features": {
+ // Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
+ "ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
diff --git a/.dockerignore b/.dockerignore
index cf975f4215f..e2f89e2f797 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -14,7 +14,8 @@ tests
# Other virtualization methods
venv
+.venv
.vagrant
# Temporary files
-**/__pycache__
\ No newline at end of file
+**/__pycache__
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 792dacd8032..c93e07dfb4f 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -55,8 +55,12 @@
creating the PR. If you're unsure about any of them, don't hesitate to ask.
We're here to help! This is simply a reminder of what we are going to look
for before merging your code.
+
+ AI tools are welcome, but contributors are responsible for *fully*
+ understanding the code before submitting a PR.
-->
+- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR.
@@ -64,6 +68,7 @@
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [ ] Tests have been added to verify that the new code works.
+- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
If user exposed functionality or configuration variables are added/changed:
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 7eba0203f7e..fc6f4a53724 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1073,7 +1073,11 @@ async def test_flow_connection_error(hass, mock_api_error):
### Entity Testing Patterns
```python
-@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True)
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Overridden fixture to specify platforms to test."""
+ return [Platform.SENSOR] # Or another specific platform as needed.
+
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
@@ -1120,16 +1124,25 @@ def mock_device_api() -> Generator[MagicMock]:
)
yield api
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return PLATFORMS
+
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
+ platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
- await hass.async_block_till_done()
+
+ with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
return mock_config_entry
```
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index c848ac793af..a446d54a4fe 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -27,12 +27,12 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: translations
path: translations.tar.gz
@@ -90,11 +90,11 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v11
+ uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v11
+ uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
- uses: actions/setup-python@v5.6.0
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations
@@ -190,14 +190,15 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
+ # home-assistant/builder doesn't support sha pinning
- name: Build base image
- uses: home-assistant/builder@2025.03.0
+ uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -242,7 +243,7 @@ jobs:
- green
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set build additional args
run: |
@@ -256,14 +257,15 @@ jobs:
fi
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
+ # home-assistant/builder doesn't support sha pinning
- name: Build base image
- uses: home-assistant/builder@2025.03.0
+ uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -279,7 +281,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -321,23 +323,23 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Cosign
- uses: sigstore/cosign-installer@v3.9.2
+ uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.2.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
- uses: docker/login-action@v3.5.0
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -454,15 +456,15 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations
@@ -480,7 +482,7 @@ jobs:
python -m build
- name: Upload package to PyPI
- uses: pypa/gh-action-pypi-publish@v1.12.4
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
@@ -502,7 +504,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Login to GitHub Container Registry
- uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -531,7 +533,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
- uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
+ uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index c4707d0b024..81a04bfb4c6 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -37,10 +37,10 @@ on:
type: boolean
env:
- CACHE_VERSION: 6
+ CACHE_VERSION: 8
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
- HA_SHORT_VERSION: "2025.9"
+ HA_SHORT_VERSION: "2025.11"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
@@ -61,6 +61,9 @@ env:
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
+ APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1
HASS_CI: 1
@@ -72,12 +75,14 @@ concurrency:
jobs:
info:
name: Collect information & changes data
+ runs-on: &runs-on-ubuntu ubuntu-24.04
outputs:
# In case of issues with the partial run, use the following line instead:
# test_full_suite: 'true'
core: ${{ steps.core.outputs.changes }}
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 }}
@@ -91,10 +96,10 @@ jobs:
tests: ${{ steps.info.outputs.tests }}
lint_only: ${{ steps.info.outputs.lint_only }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
- runs-on: ubuntu-24.04
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ - &checkout
+ name: Check out code from GitHub
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |
@@ -111,8 +116,12 @@ jobs:
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: |
+ echo "key=$(lsb_release -rs)-apt-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}" >> $GITHUB_OUTPUT
- name: Filter for core changes
- uses: dorny/paths-filter@v3.0.2
+ uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: core
with:
filters: .core_files.yaml
@@ -127,7 +136,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
- uses: dorny/paths-filter@v3.0.2
+ uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: integrations
with:
filters: .integration_paths.yaml
@@ -237,28 +246,27 @@ jobs:
pre-commit:
name: Prepare pre-commit base
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: [info]
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
- needs:
- - info
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
+ - *checkout
+ - &setup-python-default
+ name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.6.0
+ uses: &actions-setup-python actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache@v4.2.4
+ uses: &actions-cache actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
- key: >-
+ 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
@@ -271,11 +279,11 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache@v4.2.4
+ uses: *actions-cache
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
- key: >-
+ 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
@@ -286,37 +294,29 @@ jobs:
lint-ruff-format:
name: Check ruff-format
- runs-on: ubuntu-24.04
- needs:
+ runs-on: *runs-on-ubuntu
+ needs: &needs-pre-commit
- info
- pre-commit
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
- id: python
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
+ - *checkout
+ - *setup-python-default
+ - &cache-restore-pre-commit-venv
+ name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.4
+ uses: &actions-cache-restore actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
- needs.info.outputs.pre-commit_cache_key }}
- - name: Restore pre-commit environment from cache
+ key: *key-pre-commit-venv
+ - &cache-restore-pre-commit-env
+ name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.2.4
+ uses: *actions-cache-restore
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.pre-commit_cache_key }}
+ key: *key-pre-commit-env
- name: Run ruff-format
run: |
. venv/bin/activate
@@ -326,37 +326,13 @@ jobs:
lint-ruff:
name: Check ruff
- runs-on: ubuntu-24.04
- needs:
- - info
- - pre-commit
+ runs-on: *runs-on-ubuntu
+ needs: *needs-pre-commit
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
- id: python
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
- needs.info.outputs.pre-commit_cache_key }}
- - name: Restore pre-commit environment from cache
- id: cache-precommit
- uses: actions/cache/restore@v4.2.4
- with:
- path: ${{ env.PRE_COMMIT_CACHE }}
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.pre-commit_cache_key }}
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-pre-commit-venv
+ - *cache-restore-pre-commit-env
- name: Run ruff
run: |
. venv/bin/activate
@@ -366,37 +342,13 @@ jobs:
lint-other:
name: Check other linters
- runs-on: ubuntu-24.04
- needs:
- - info
- - pre-commit
+ runs-on: *runs-on-ubuntu
+ needs: *needs-pre-commit
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
- id: python
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
- needs.info.outputs.pre-commit_cache_key }}
- - name: Restore pre-commit environment from cache
- id: cache-precommit
- uses: actions/cache/restore@v4.2.4
- with:
- path: ${{ env.PRE_COMMIT_CACHE }}
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.pre-commit_cache_key }}
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-pre-commit-venv
+ - *cache-restore-pre-commit-env
- name: Register yamllint problem matcher
run: |
@@ -446,9 +398,8 @@ jobs:
lint-hadolint:
name: Check ${{ matrix.file }}
- runs-on: ubuntu-24.04
- needs:
- - info
+ runs-on: *runs-on-ubuntu
+ needs: [info]
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
@@ -461,8 +412,7 @@ jobs:
- Dockerfile.dev
- script/hassfest/docker/Dockerfile
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ - *checkout
- name: Register hadolint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -473,18 +423,18 @@ jobs:
base:
name: Prepare dependencies
- runs-on: ubuntu-24.04
- needs: info
+ runs-on: *runs-on-ubuntu
+ needs: [info]
timeout-minutes: 60
strategy:
matrix:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
+ - *checkout
+ - &setup-python-matrix
+ name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.6.0
+ uses: *actions-setup-python
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -497,15 +447,15 @@ 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@v4.2.4
+ uses: *actions-cache
with:
path: venv
- key: >-
+ key: &key-python-venv >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
- uses: actions/cache@v4.2.4
+ uses: *actions-cache
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -515,15 +465,38 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}-
+ - name: Check if apt cache exists
+ id: cache-apt-check
+ uses: *actions-cache
+ with:
+ lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
+ path: &path-apt-cache |
+ ${{ env.APT_CACHE_DIR }}
+ ${{ env.APT_LIST_CACHE_DIR }}
+ key: &key-apt-cache >-
+ ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
- if: steps.cache-venv.outputs.cache-hit != 'true'
+ if: |
+ steps.cache-venv.outputs.cache-hit != 'true'
+ || steps.cache-apt-check.outputs.cache-hit != 'true'
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
+ mkdir -p ${{ env.APT_CACHE_DIR }}
+ mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
+ fi
+
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
+ libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
@@ -533,6 +506,18 @@ jobs:
libswresample-dev \
libswscale-dev \
libudev-dev
+
+ if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
+ sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
+ fi
+ - name: Save apt cache
+ if: steps.cache-apt-check.outputs.cache-hit != 'true'
+ uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ${{ env.APT_CACHE_DIR }}
+ ${{ env.APT_LIST_CACHE_DIR }}
+ key: *key-apt-cache
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -552,7 +537,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
- uses: actions/upload-artifact@v4.6.2
+ uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -562,44 +547,50 @@ jobs:
- name: Remove generated requirements_all
if: steps.cache-venv.outputs.cache-hit != 'true'
run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt
- - name: Check dirty
+ - &check-dirty
+ name: Check dirty
run: |
./script/check_dirty
hassfest:
name: Check hassfest
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: &needs-base
+ - info
+ - base
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
- needs:
- - info
- - base
steps:
+ - &cache-restore-apt
+ name: Restore apt cache
+ uses: *actions-cache-restore
+ with:
+ path: *path-apt-cache
+ fail-on-cache-miss: true
+ key: *key-apt-cache
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
+ - *checkout
+ - *setup-python-default
+ - &cache-restore-python-default
+ name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.4
+ uses: *actions-cache-restore
with:
path: venv
fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
+ key: *key-python-venv
- name: Run hassfest
run: |
. venv/bin/activate
@@ -607,32 +598,16 @@ jobs:
gen-requirements-all:
name: Check all requirements
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
- needs:
- - info
- - base
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-python-default
- name: Run gen_requirements_all.py
run: |
. venv/bin/activate
@@ -640,29 +615,24 @@ jobs:
dependency-review:
name: Dependency review
- runs-on: ubuntu-24.04
- needs:
- - info
- - base
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& needs.info.outputs.requirements == 'true'
&& github.event_name == 'pull_request'
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ - *checkout
- name: Dependency review
- uses: actions/dependency-review-action@v4.7.2
+ uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
license-check: false # We use our own license audit checks
audit-licenses:
name: Audit licenses
- runs-on: ubuntu-24.04
- needs:
- - info
- - base
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
if: |
(github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
@@ -673,29 +643,22 @@ jobs:
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ matrix.python-version }}
- check-latest: true
- - name: Restore full Python ${{ matrix.python-version }} virtual environment
+ - *checkout
+ - *setup-python-matrix
+ - &cache-restore-python-matrix
+ name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.4
+ uses: *actions-cache-restore
with:
path: venv
fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
+ key: *key-python-venv
- name: Extract license data
run: |
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -706,34 +669,19 @@ jobs:
pylint:
name: Check pylint
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
timeout-minutes: 20
if: |
github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.pylint-only == 'true'
- needs:
- - info
- - base
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register pylint problem matcher
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-python-default
+ - &problem-matcher-pylint
+ name: Register pylint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/pylint.json"
- name: Run pylint (fully)
@@ -752,37 +700,19 @@ jobs:
pylint-tests:
name: Check pylint on tests
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
timeout-minutes: 20
if: |
(github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.pylint-only == 'true')
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
- needs:
- - info
- - base
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register pylint problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/pylint.json"
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-python-default
+ - *problem-matcher-pylint
- name: Run pylint (fully)
if: needs.info.outputs.test_full_suite == 'true'
run: |
@@ -799,23 +729,15 @@ jobs:
mypy:
name: Check mypy
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ needs: *needs-base
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.mypy-only == 'true'
- needs:
- - info
- - base
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
+ - *checkout
+ - *setup-python-default
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
@@ -823,17 +745,9 @@ jobs:
echo "version=$mypy_version" >> $GITHUB_OUTPUT
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
+ - *cache-restore-python-default
- name: Restore mypy cache
- uses: actions/cache@v4.2.4
+ uses: *actions-cache
with:
path: .mypy_cache
key: >-
@@ -861,7 +775,8 @@ jobs:
mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }}
prepare-pytest-full:
- runs-on: ubuntu-24.04
+ name: Split tests for full run
+ runs-on: *runs-on-ubuntu
if: |
needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
@@ -874,50 +789,39 @@ jobs:
- lint-ruff
- lint-ruff-format
- mypy
- name: Split tests for full run
steps:
+ - *cache-restore-apt
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
libgammu-dev
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- check-latest: true
- - name: Restore base Python virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
+ - *checkout
+ - *setup-python-default
+ - *cache-restore-python-default
- name: Run split_tests.py
run: |
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: pytest_buckets
path: pytest_buckets.txt
overwrite: true
pytest-full:
- runs-on: ubuntu-24.04
- if: |
- needs.info.outputs.lint_only != 'true'
- && needs.info.outputs.test_full_suite == 'true'
+ name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
+ runs-on: *runs-on-ubuntu
needs:
- info
- base
@@ -928,52 +832,48 @@ jobs:
- lint-ruff-format
- mypy
- prepare-pytest-full
+ if: |
+ needs.info.outputs.lint_only != 'true'
+ && needs.info.outputs.test_full_suite == 'true'
strategy:
fail-fast: false
matrix:
- group: ${{ fromJson(needs.info.outputs.test_groups) }}
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
- name: >-
- Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
+ group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
+ - *cache-restore-apt
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
libgammu-dev \
libxml2-utils
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ matrix.python-version }}
- check-latest: true
- - name: Restore full Python ${{ matrix.python-version }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register Python problem matcher
+ - *checkout
+ - *setup-python-matrix
+ - *cache-restore-python-matrix
+ - &problem-matcher-python
+ name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
- - name: Register pytest slow test problem matcher
+ - &problem-matcher-pytest-slow
+ name: Register pytest slow test problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
- uses: actions/download-artifact@v5.0.0
+ uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: pytest_buckets
- - name: Compile English translations
+ - &compile-english-translations
+ name: Compile English translations
run: |
. venv/bin/activate
python3 -m script.translations develop --all
@@ -1009,19 +909,20 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
- - name: Beautify test results
+ - &beautify-test-results
+ name: Beautify test results
# For easier identification of parsing errors
if: needs.info.outputs.skip_coverage != 'true'
run: |
@@ -1029,18 +930,17 @@ jobs:
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
- name: Remove pytest_buckets
run: rm pytest_buckets.txt
- - name: Check dirty
- run: |
- ./script/check_dirty
+ - *check-dirty
pytest-mariadb:
- runs-on: ubuntu-24.04
+ name: Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }}
+ runs-on: *runs-on-ubuntu
services:
mariadb:
image: ${{ matrix.mariadb-group }}
@@ -1049,9 +949,6 @@ jobs:
env:
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
- if: |
- needs.info.outputs.lint_only != 'true'
- && needs.info.outputs.mariadb_groups != '[]'
needs:
- info
- base
@@ -1061,55 +958,41 @@ jobs:
- lint-ruff
- lint-ruff-format
- mypy
+ if: |
+ needs.info.outputs.lint_only != 'true'
+ && needs.info.outputs.mariadb_groups != '[]'
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
- name: >-
- Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }}
steps:
+ - *cache-restore-apt
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
libmariadb-dev-compat \
libxml2-utils
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ matrix.python-version }}
- check-latest: true
- - name: Restore full Python ${{ matrix.python-version }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register Python problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/python.json"
- - name: Register pytest slow test problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
+ - *checkout
+ - *setup-python-matrix
+ - *cache-restore-python-matrix
+ - *problem-matcher-python
+ - *problem-matcher-pytest-slow
- name: Install SQL Python libraries
run: |
. venv/bin/activate
uv pip install mysqlclient sqlalchemy_utils
- - name: Compile English translations
- run: |
- . venv/bin/activate
- python3 -m script.translations develop --all
+ - *compile-english-translations
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
@@ -1148,7 +1031,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1156,31 +1039,25 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: coverage.xml
overwrite: true
- - name: Beautify test results
- # For easier identification of parsing errors
- if: needs.info.outputs.skip_coverage != 'true'
- run: |
- xmllint --format "junit.xml" > "junit.xml-tmp"
- mv "junit.xml-tmp" "junit.xml"
+ - *beautify-test-results
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: test-results-mariadb-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: junit.xml
- - name: Check dirty
- run: |
- ./script/check_dirty
+ - *check-dirty
pytest-postgres:
- runs-on: ubuntu-24.04
+ name: Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }}
+ runs-on: *runs-on-ubuntu
services:
postgres:
image: ${{ matrix.postgresql-group }}
@@ -1189,9 +1066,6 @@ jobs:
env:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
- if: |
- needs.info.outputs.lint_only != 'true'
- && needs.info.outputs.postgresql_groups != '[]'
needs:
- info
- base
@@ -1201,19 +1075,26 @@ jobs:
- lint-ruff
- lint-ruff-format
- mypy
+ if: |
+ needs.info.outputs.lint_only != 'true'
+ && needs.info.outputs.postgresql_groups != '[]'
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
- name: >-
- Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }}
steps:
+ - *cache-restore-apt
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
@@ -1221,37 +1102,16 @@ jobs:
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \
postgresql-server-dev-14
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ matrix.python-version }}
- check-latest: true
- - name: Restore full Python ${{ matrix.python-version }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register Python problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/python.json"
- - name: Register pytest slow test problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
+ - *checkout
+ - *setup-python-matrix
+ - *cache-restore-python-matrix
+ - *problem-matcher-python
+ - *problem-matcher-pytest-slow
- name: Install SQL Python libraries
run: |
. venv/bin/activate
uv pip install psycopg2 sqlalchemy_utils
- - name: Compile English translations
- run: |
- . venv/bin/activate
- python3 -m script.translations develop --all
+ - *compile-english-translations
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
@@ -1291,7 +1151,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1299,60 +1159,49 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: coverage.xml
overwrite: true
- - name: Beautify test results
- # For easier identification of parsing errors
- if: needs.info.outputs.skip_coverage != 'true'
- run: |
- xmllint --format "junit.xml" > "junit.xml-tmp"
- mv "junit.xml-tmp" "junit.xml"
+ - *beautify-test-results
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: test-results-postgres-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: junit.xml
- - name: Check dirty
- run: |
- ./script/check_dirty
+ - *check-dirty
coverage-full:
name: Upload test coverage to Codecov (full suite)
- if: needs.info.outputs.skip_coverage != 'true'
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
needs:
- info
- pytest-full
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
+ if: needs.info.outputs.skip_coverage != 'true'
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ - *checkout
- name: Download all coverage artifacts
- uses: actions/download-artifact@v5.0.0
+ uses: *actions-download-artifact
with:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
- uses: codecov/codecov-action@v5.5.0
+ uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }}
pytest-partial:
- runs-on: ubuntu-24.04
- if: |
- needs.info.outputs.lint_only != 'true'
- && needs.info.outputs.tests_glob
- && needs.info.outputs.test_full_suite == 'false'
+ name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
+ runs-on: *runs-on-ubuntu
needs:
- info
- base
@@ -1362,51 +1211,38 @@ jobs:
- lint-ruff
- lint-ruff-format
- mypy
+ if: |
+ needs.info.outputs.lint_only != 'true'
+ && needs.info.outputs.tests_glob
+ && needs.info.outputs.test_full_suite == 'false'
strategy:
fail-fast: false
matrix:
- group: ${{ fromJson(needs.info.outputs.test_groups) }}
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
- name: >-
- Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
+ group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
+ - *cache-restore-apt
- name: Install additional OS dependencies
+ timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
- sudo apt-get update
+ sudo apt-get update \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }}
sudo apt-get -y install \
+ -o Dir::Cache=${{ env.APT_CACHE_DIR }} \
+ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
bluez \
ffmpeg \
libturbojpeg \
libgammu-dev \
libxml2-utils
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
- - name: Set up Python ${{ matrix.python-version }}
- id: python
- uses: actions/setup-python@v5.6.0
- with:
- python-version: ${{ matrix.python-version }}
- check-latest: true
- - name: Restore full Python ${{ matrix.python-version }} virtual environment
- id: cache-venv
- uses: actions/cache/restore@v4.2.4
- with:
- path: venv
- fail-on-cache-miss: true
- key: >-
- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
- needs.info.outputs.python_cache_key }}
- - name: Register Python problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/python.json"
- - name: Register pytest slow test problem matcher
- run: |
- echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- - name: Compile English translations
- run: |
- . venv/bin/activate
- python3 -m script.translations develop --all
+ - *checkout
+ - *setup-python-matrix
+ - *cache-restore-python-matrix
+ - *problem-matcher-python
+ - *problem-matcher-pytest-slow
+ - *compile-english-translations
- name: Run pytest
timeout-minutes: 10
id: pytest-partial
@@ -1446,62 +1282,51 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
- - name: Beautify test results
- # For easier identification of parsing errors
- if: needs.info.outputs.skip_coverage != 'true'
- run: |
- xmllint --format "junit.xml" > "junit.xml-tmp"
- mv "junit.xml-tmp" "junit.xml"
+ - *beautify-test-results
- name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
- uses: actions/upload-artifact@v4.6.2
+ uses: *actions-upload-artifact
with:
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml
- - name: Check dirty
- run: |
- ./script/check_dirty
+ - *check-dirty
coverage-partial:
name: Upload test coverage to Codecov (partial suite)
if: needs.info.outputs.skip_coverage != 'true'
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
+ timeout-minutes: 10
needs:
- info
- pytest-partial
- timeout-minutes: 10
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ - *checkout
- name: Download all coverage artifacts
- uses: actions/download-artifact@v5.0.0
+ uses: *actions-download-artifact
with:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
- uses: codecov/codecov-action@v5.5.0
+ uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
upload-test-results:
name: Upload test results to Codecov
- # codecov/test-results-action currently doesn't support tokenless uploads
- # therefore we can't run it on forks
- if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }}
- runs-on: ubuntu-24.04
+ runs-on: *runs-on-ubuntu
needs:
- info
- pytest-partial
@@ -1509,13 +1334,18 @@ jobs:
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
+ # codecov/test-results-action currently doesn't support tokenless uploads
+ # therefore we can't run it on forks
+ if: |
+ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
+ && needs.info.outputs.skip_coverage != 'true' && !cancelled()
steps:
- name: Download all coverage artifacts
- uses: actions/download-artifact@v5.0.0
+ uses: *actions-download-artifact
with:
pattern: test-results-*
- name: Upload test results to Codecov
- uses: codecov/test-results-action@v1
+ uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
fail_ci_if_error: true
verbose: true
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 28cdd83a198..14ee6803732 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.29.11
+ uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.29.11
+ uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
category: "/language:python"
diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml
index 5f9522e0593..801c4bb36bc 100644
--- a/.github/workflows/detect-duplicate-issues.yml
+++ b/.github/workflows/detect-duplicate-issues.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Check if integration label was added and extract details
id: extract
- uses: actions/github-script@v7.0.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
// Debug: Log the event payload
@@ -113,7 +113,7 @@ jobs:
- name: Fetch similar issues
id: fetch_similar
if: steps.extract.outputs.should_continue == 'true'
- uses: actions/github-script@v7.0.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
@@ -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@v2.0.0
+ uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
model: openai/gpt-4o
system-prompt: |
@@ -280,7 +280,7 @@ jobs:
- name: Post duplicate detection results
id: post_results
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
- uses: actions/github-script@v7.0.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml
index bcad5726968..ec569f63ca3 100644
--- a/.github/workflows/detect-non-english-issues.yml
+++ b/.github/workflows/detect-non-english-issues.yml
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Check issue language
id: detect_language
- uses: actions/github-script@v7.0.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
@@ -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@v2.0.0
+ uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
with:
model: openai/gpt-4o-mini
system-prompt: |
@@ -90,7 +90,7 @@ jobs:
- name: Process non-English issues
if: steps.detect_language.outputs.should_continue == 'true'
- uses: actions/github-script@v7.0.1
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index fb5deb2958f..daaa7374713 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v5.0.1
+ - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"
diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml
index 36d9688f50a..1b78cae3e0f 100644
--- a/.github/workflows/restrict-task-creation.yml
+++ b/.github/workflows/restrict-task-creation.yml
@@ -12,7 +12,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
- uses: actions/github-script@v7
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 11c87266525..86be8cd4da5 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
- uses: actions/stale@v9.1.0
+ uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
- uses: actions/stale@v9.1.0
+ uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
- uses: actions/stale@v9.1.0
+ uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index 004b552cab3..fb4cb43e7c0 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.6.0
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 883cc688cf5..b6a4d0832f7 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -32,11 +32,11 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.6.0
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: env_file
path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true
- name: Upload build_constraints
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
- uses: actions/upload-artifact@v4.6.2
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -135,20 +135,20 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Download env_file
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: env_file
- name: Download build_constraints
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: build_constraints
- name: Download requirements_diff
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: requirements_diff
@@ -158,8 +158,9 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
+ # home-assistant/wheels doesn't support sha pinning
- name: Build wheels
- uses: home-assistant/wheels@2025.07.0
+ uses: home-assistant/wheels@2025.09.1
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -184,25 +185,25 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
- uses: actions/checkout@v5.0.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Download env_file
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: env_file
- name: Download build_constraints
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: build_constraints
- name: Download requirements_diff
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: requirements_diff
- name: Download requirements_all_wheels
- uses: actions/download-artifact@v5.0.0
+ uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: requirements_all_wheels
@@ -218,8 +219,9 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
+ # home-assistant/wheels doesn't support sha pinning
- name: Build wheels
- uses: home-assistant/wheels@2025.07.0
+ uses: home-assistant/wheels@2025.09.1
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
diff --git a/.gitignore b/.gitignore
index 9bcf440a2f1..bcd3e3d95d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -140,5 +140,5 @@ tmp_cache
pytest_buckets.txt
# AI tooling
-.claude
+.claude/settings.local.json
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d87187b55be..982b73084f0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.12.1
+ rev: v0.13.0
hooks:
- id: ruff-check
args:
diff --git a/.strict-typing b/.strict-typing
index b3e41747239..291c3d78e67 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -142,6 +142,7 @@ homeassistant.components.cloud.*
homeassistant.components.co2signal.*
homeassistant.components.comelit.*
homeassistant.components.command_line.*
+homeassistant.components.compit.*
homeassistant.components.config.*
homeassistant.components.configurator.*
homeassistant.components.cookidoo.*
@@ -169,6 +170,7 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
+homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
homeassistant.components.dunehd.*
@@ -201,6 +203,7 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.*
homeassistant.components.filesize.*
homeassistant.components.filter.*
+homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
@@ -307,6 +310,7 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
+homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
@@ -322,6 +326,7 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
+homeassistant.components.lunatone.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*
@@ -382,6 +387,7 @@ homeassistant.components.openai_conversation.*
homeassistant.components.openexchangerates.*
homeassistant.components.opensky.*
homeassistant.components.openuv.*
+homeassistant.components.opnsense.*
homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
@@ -399,6 +405,7 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
+homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.*
@@ -438,6 +445,7 @@ homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roborock.*
homeassistant.components.roku.*
homeassistant.components.romy.*
+homeassistant.components.route_b_smart_meter.*
homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.*
homeassistant.components.russound_rio.*
@@ -458,6 +466,7 @@ homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*
+homeassistant.components.sftp_storage.*
homeassistant.components.shell_command.*
homeassistant.components.shelly.*
homeassistant.components.shopping_list.*
@@ -546,6 +555,7 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
+homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*
diff --git a/CODEOWNERS b/CODEOWNERS
index 0cdd46192a8..1c01270ff46 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -87,6 +87,8 @@ build.json @home-assistant/supervisor
/tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari
+/homeassistant/components/aladdin_connect/ @swcloudgenie
+/tests/components/aladdin_connect/ @swcloudgenie
/homeassistant/components/alarm_control_panel/ @home-assistant/core
/tests/components/alarm_control_panel/ @home-assistant/core
/homeassistant/components/alert/ @home-assistant/core @frenck
@@ -105,8 +107,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/ambient_station/ @bachya
/tests/components/ambient_station/ @bachya
/homeassistant/components/amcrest/ @flacjacket
-/homeassistant/components/analytics/ @home-assistant/core @ludeeus
-/tests/components/analytics/ @home-assistant/core @ludeeus
+/homeassistant/components/analytics/ @home-assistant/core
+/tests/components/analytics/ @home-assistant/core
/homeassistant/components/analytics_insights/ @joostlek
/tests/components/analytics_insights/ @joostlek
/homeassistant/components/android_ip_webcam/ @engrbm87
@@ -152,10 +154,10 @@ build.json @home-assistant/supervisor
/tests/components/arve/ @ikalnyi
/homeassistant/components/aseko_pool_live/ @milanmeu
/tests/components/aseko_pool_live/ @milanmeu
-/homeassistant/components/assist_pipeline/ @balloob @synesthesiam
-/tests/components/assist_pipeline/ @balloob @synesthesiam
-/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam
-/tests/components/assist_satellite/ @home-assistant/core @synesthesiam
+/homeassistant/components/assist_pipeline/ @synesthesiam @arturpragacz
+/tests/components/assist_pipeline/ @synesthesiam @arturpragacz
+/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam @arturpragacz
+/tests/components/assist_satellite/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/homeassistant/components/atag/ @MatsNL
@@ -290,14 +292,16 @@ build.json @home-assistant/supervisor
/tests/components/command_line/ @gjohansson-ST
/homeassistant/components/compensation/ @Petro31
/tests/components/compensation/ @Petro31
+/homeassistant/components/compit/ @Przemko92
+/tests/components/compit/ @Przemko92
/homeassistant/components/config/ @home-assistant/core
/tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core
/tests/components/configurator/ @home-assistant/core
/homeassistant/components/control4/ @lawtancool
/tests/components/control4/ @lawtancool
-/homeassistant/components/conversation/ @home-assistant/core @synesthesiam
-/tests/components/conversation/ @home-assistant/core @synesthesiam
+/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
+/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/cookidoo/ @miaucl
/tests/components/cookidoo/ @miaucl
/homeassistant/components/coolmaster/ @OnFreund
@@ -312,6 +316,8 @@ build.json @home-assistant/supervisor
/tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff
+/homeassistant/components/cync/ @Kinachi249
+/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core
@@ -375,6 +381,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
+/homeassistant/components/droplet/ @sarahseidman
+/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221
/tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
@@ -404,6 +412,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/eheimdigital/ @autinerd
/tests/components/eheimdigital/ @autinerd
+/homeassistant/components/ekeybionyx/ @richardpolzer
+/tests/components/ekeybionyx/ @richardpolzer
/homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
@@ -438,8 +448,6 @@ build.json @home-assistant/supervisor
/tests/components/energyzero/ @klaasnicolaas
/homeassistant/components/enigma2/ @autinerd
/tests/components/enigma2/ @autinerd
-/homeassistant/components/enocean/ @bdurrer
-/tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten
@@ -462,8 +470,6 @@ build.json @home-assistant/supervisor
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core
/tests/components/event/ @home-assistant/core
-/homeassistant/components/evil_genius_labs/ @balloob
-/tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26
@@ -486,6 +492,8 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
+/homeassistant/components/firefly_iii/ @erwindouna
+/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
@@ -515,8 +523,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/forked_daapd/ @uvjustin
/tests/components/forked_daapd/ @uvjustin
/homeassistant/components/fortios/ @kimfrellsen
-/homeassistant/components/foscam/ @krmarien
-/tests/components/foscam/ @krmarien
+/homeassistant/components/foscam/ @Foscam-wangzhengyu
+/tests/components/foscam/ @Foscam-wangzhengyu
/homeassistant/components/freebox/ @hacf-fr @Quentame
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
@@ -650,6 +658,8 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
/tests/components/homeassistant_alerts/ @home-assistant/core
+/homeassistant/components/homeassistant_connect_zbt2/ @home-assistant/core
+/tests/components/homeassistant_connect_zbt2/ @home-assistant/core
/homeassistant/components/homeassistant_green/ @home-assistant/core
/tests/components/homeassistant_green/ @home-assistant/core
/homeassistant/components/homeassistant_hardware/ @home-assistant/core
@@ -678,8 +688,8 @@ build.json @home-assistant/supervisor
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
/tests/components/huawei_lte/ @scop @fphammerle
-/homeassistant/components/hue/ @balloob @marcelveldt
-/tests/components/hue/ @balloob @marcelveldt
+/homeassistant/components/hue/ @marcelveldt
+/tests/components/hue/ @marcelveldt
/homeassistant/components/huisbaasje/ @dennisschroer
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
@@ -751,8 +761,8 @@ build.json @home-assistant/supervisor
/tests/components/integration/ @dgomes
/homeassistant/components/intellifire/ @jeeftor
/tests/components/intellifire/ @jeeftor
-/homeassistant/components/intent/ @home-assistant/core @synesthesiam
-/tests/components/intent/ @home-assistant/core @synesthesiam
+/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
+/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe
@@ -770,6 +780,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iqvia/ @bachya
/tests/components/iqvia/ @bachya
/homeassistant/components/irish_rail_transport/ @ttroy50
+/homeassistant/components/irm_kmi/ @jdejaegh
+/tests/components/irm_kmi/ @jdejaegh
/homeassistant/components/iron_os/ @tr4nt0r
/tests/components/iron_os/ @tr4nt0r
/homeassistant/components/isal/ @bdraco
@@ -860,6 +872,8 @@ build.json @home-assistant/supervisor
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
/tests/components/lg_thinq/ @LG-ThinQ-Integration
+/homeassistant/components/libre_hardware_monitor/ @Sab44
+/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/lifx/ @Djelibeybi
@@ -898,6 +912,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck
+/homeassistant/components/lunatone/ @MoonDevLT
+/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -943,6 +959,8 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
+/homeassistant/components/meteo_lt/ @xE1H
+/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo
@@ -1013,7 +1031,8 @@ build.json @home-assistant/supervisor
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
-/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
+/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
+/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
/homeassistant/components/nest/ @allenporter
@@ -1108,8 +1127,6 @@ build.json @home-assistant/supervisor
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
-/homeassistant/components/openai_conversation/ @balloob
-/tests/components/openai_conversation/ @balloob
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openexchangerates/ @MartinHjelmare
@@ -1181,12 +1198,14 @@ build.json @home-assistant/supervisor
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew
-/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
-/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
/tests/components/point/ @fredrike
+/homeassistant/components/pooldose/ @lmaertin
+/tests/components/pooldose/ @lmaertin
/homeassistant/components/poolsense/ @haemishkyd
/tests/components/poolsense/ @haemishkyd
+/homeassistant/components/portainer/ @erwindouna
+/tests/components/portainer/ @erwindouna
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
@@ -1206,8 +1225,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
-/homeassistant/components/prusalink/ @balloob
-/tests/components/prusalink/ @balloob
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato
@@ -1301,8 +1318,8 @@ build.json @home-assistant/supervisor
/tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
/tests/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
-/homeassistant/components/rhasspy/ @balloob @synesthesiam
-/tests/components/rhasspy/ @balloob @synesthesiam
+/homeassistant/components/rhasspy/ @synesthesiam
+/tests/components/rhasspy/ @synesthesiam
/homeassistant/components/ridwell/ @bachya
/tests/components/ridwell/ @bachya
/homeassistant/components/ring/ @sdb9696
@@ -1323,6 +1340,8 @@ build.json @home-assistant/supervisor
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
+/homeassistant/components/route_b_smart_meter/ @SeraphicRav
+/tests/components/route_b_smart_meter/ @SeraphicRav
/homeassistant/components/rpi_power/ @shenxn @swetoast
/tests/components/rpi_power/ @shenxn @swetoast
/homeassistant/components/rss_feed_template/ @home-assistant/core
@@ -1345,6 +1364,8 @@ build.json @home-assistant/supervisor
/tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/sanix/ @tomaszsluszniak
/tests/components/sanix/ @tomaszsluszniak
+/homeassistant/components/satel_integra/ @Tommatheussen
+/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core
@@ -1390,12 +1411,14 @@ build.json @home-assistant/supervisor
/tests/components/seventeentrack/ @shaiu
/homeassistant/components/sfr_box/ @epenet
/tests/components/sfr_box/ @epenet
+/homeassistant/components/sftp_storage/ @maretodoric
+/tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core
-/homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
-/tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
+/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
+/tests/components/shelly/ @bieniu @thecode @chemelli74 @bdraco
/homeassistant/components/shodan/ @fabaff
/homeassistant/components/sia/ @eavanvalkenburg
/tests/components/sia/ @eavanvalkenburg
@@ -1524,8 +1547,8 @@ build.json @home-assistant/supervisor
/tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
-/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
-/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
+/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
+/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
/tests/components/switcher_kis/ @thecode @YogevBokobza
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
@@ -1542,8 +1565,8 @@ build.json @home-assistant/supervisor
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
-/homeassistant/components/tag/ @balloob @dmulcahey
-/tests/components/tag/ @balloob @dmulcahey
+/homeassistant/components/tag/ @home-assistant/core
+/tests/components/tag/ @home-assistant/core
/homeassistant/components/tailscale/ @frenck
/tests/components/tailscale/ @frenck
/homeassistant/components/tailwind/ @frenck
@@ -1670,6 +1693,8 @@ build.json @home-assistant/supervisor
/tests/components/uptime_kuma/ @tr4nt0r
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
/tests/components/uptimerobot/ @ludeeus @chemelli74
+/homeassistant/components/usage_prediction/ @home-assistant/core
+/tests/components/usage_prediction/ @home-assistant/core
/homeassistant/components/usb/ @bdraco
/tests/components/usb/ @bdraco
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte
@@ -1688,17 +1713,19 @@ build.json @home-assistant/supervisor
/tests/components/vegehub/ @ghowevege
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
-/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
-/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
+/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
+/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus
-/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
-/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
+/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
+/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
+/homeassistant/components/victron_remote_monitoring/ @AndyTempel
+/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel
@@ -1708,16 +1735,14 @@ build.json @home-assistant/supervisor
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74
-/homeassistant/components/voip/ @balloob @synesthesiam @jaminh
-/tests/components/voip/ @balloob @synesthesiam @jaminh
+/homeassistant/components/voip/ @synesthesiam @jaminh
+/tests/components/voip/ @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
-/homeassistant/components/volvooncall/ @molobrakos
-/tests/components/volvooncall/ @molobrakos
-/homeassistant/components/vulcan/ @Antoni-Czaplicki
-/tests/components/vulcan/ @Antoni-Czaplicki
+/homeassistant/components/volvooncall/ @molobrakos @svrooij
+/tests/components/volvooncall/ @molobrakos @svrooij
/homeassistant/components/wake_on_lan/ @ntilley905
/tests/components/wake_on_lan/ @ntilley905
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
@@ -1782,8 +1807,8 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
-/homeassistant/components/wyoming/ @balloob @synesthesiam
-/tests/components/wyoming/ @balloob @synesthesiam
+/homeassistant/components/wyoming/ @synesthesiam
+/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 4c037799567..c16ca2c9522 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -3,8 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN \
- curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
- && apt-get update \
+ apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
# Additional library needed by some tests and accordingly by VScode Tests Discovery
bluez \
diff --git a/build.yaml b/build.yaml
index 00df4196523..60b6fa5ef3a 100644
--- a/build.yaml
+++ b/build.yaml
@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
- aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
- armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
- armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
- amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
- i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
+ aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
+ armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
+ armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
+ amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
+ i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py
index 6fd48c4809c..7821caac749 100644
--- a/homeassistant/__main__.py
+++ b/homeassistant/__main__.py
@@ -187,36 +187,42 @@ def main() -> int:
from . import config, runner # noqa: PLC0415
- safe_mode = config.safe_mode_enabled(config_dir)
+ # Ensure only one instance runs per config directory
+ with runner.ensure_single_execution(config_dir) as single_execution_lock:
+ # Check if another instance is already running
+ if single_execution_lock.exit_code is not None:
+ return single_execution_lock.exit_code
- runtime_conf = runner.RuntimeConfig(
- config_dir=config_dir,
- verbose=args.verbose,
- log_rotate_days=args.log_rotate_days,
- log_file=args.log_file,
- log_no_color=args.log_no_color,
- skip_pip=args.skip_pip,
- skip_pip_packages=args.skip_pip_packages,
- recovery_mode=args.recovery_mode,
- debug=args.debug,
- open_ui=args.open_ui,
- safe_mode=safe_mode,
- )
+ safe_mode = config.safe_mode_enabled(config_dir)
- fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
- with open(fault_file_name, mode="a", encoding="utf8") as fault_file:
- faulthandler.enable(fault_file)
- exit_code = runner.run(runtime_conf)
- faulthandler.disable()
+ runtime_conf = runner.RuntimeConfig(
+ config_dir=config_dir,
+ verbose=args.verbose,
+ log_rotate_days=args.log_rotate_days,
+ log_file=args.log_file,
+ log_no_color=args.log_no_color,
+ skip_pip=args.skip_pip,
+ skip_pip_packages=args.skip_pip_packages,
+ recovery_mode=args.recovery_mode,
+ debug=args.debug,
+ open_ui=args.open_ui,
+ safe_mode=safe_mode,
+ )
- # It's possible for the fault file to disappear, so suppress obvious errors
- with suppress(FileNotFoundError):
- if os.path.getsize(fault_file_name) == 0:
- os.remove(fault_file_name)
+ fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
+ with open(fault_file_name, mode="a", encoding="utf8") as fault_file:
+ faulthandler.enable(fault_file)
+ exit_code = runner.run(runtime_conf)
+ faulthandler.disable()
- check_threads()
+ # It's possible for the fault file to disappear, so suppress obvious errors
+ with suppress(FileNotFoundError):
+ if os.path.getsize(fault_file_name) == 0:
+ os.remove(fault_file_name)
- return exit_code
+ check_threads()
+
+ return exit_code
if __name__ == "__main__":
diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py
index 978758bebb1..fffee79da66 100644
--- a/homeassistant/auth/mfa_modules/notify.py
+++ b/homeassistant/auth/mfa_modules/notify.py
@@ -27,7 +27,7 @@ from . import (
SetupFlow,
)
-REQUIREMENTS = ["pyotp==2.8.0"]
+REQUIREMENTS = ["pyotp==2.9.0"]
CONF_MESSAGE = "message"
diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py
index b344043b832..2128d874390 100644
--- a/homeassistant/auth/mfa_modules/totp.py
+++ b/homeassistant/auth/mfa_modules/totp.py
@@ -20,7 +20,7 @@ from . import (
SetupFlow,
)
-REQUIREMENTS = ["pyotp==2.8.0", "PyQRCode==1.2.1"]
+REQUIREMENTS = ["pyotp==2.9.0", "PyQRCode==1.2.1"]
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA)
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 4e49d6cec7e..24268f4f4e2 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -616,34 +616,44 @@ async def async_enable_logging(
),
)
- # Log errors to a file if we have write access to file or config dir
+ logger = logging.getLogger()
+ logger.setLevel(logging.INFO if verbose else logging.WARNING)
+
if log_file is None:
- err_log_path = hass.config.path(ERROR_LOG_FILENAME)
+ default_log_path = hass.config.path(ERROR_LOG_FILENAME)
+ if "SUPERVISOR" in os.environ:
+ _LOGGER.info("Running in Supervisor, not logging to file")
+ # Rename the default log file if it exists, since previous versions created
+ # it even on Supervisor
+ if os.path.isfile(default_log_path):
+ with contextlib.suppress(OSError):
+ os.rename(default_log_path, f"{default_log_path}.old")
+ err_log_path = None
+ else:
+ err_log_path = default_log_path
else:
err_log_path = os.path.abspath(log_file)
- err_path_exists = os.path.isfile(err_log_path)
- err_dir = os.path.dirname(err_log_path)
+ if err_log_path:
+ err_path_exists = os.path.isfile(err_log_path)
+ err_dir = os.path.dirname(err_log_path)
- # Check if we can write to the error log if it exists or that
- # we can create files in the containing directory if not.
- if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
- not err_path_exists and os.access(err_dir, os.W_OK)
- ):
- err_handler = await hass.async_add_executor_job(
- _create_log_file, err_log_path, log_rotate_days
- )
+ # Check if we can write to the error log if it exists or that
+ # we can create files in the containing directory if not.
+ if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
+ not err_path_exists and os.access(err_dir, os.W_OK)
+ ):
+ err_handler = await hass.async_add_executor_job(
+ _create_log_file, err_log_path, log_rotate_days
+ )
- err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
+ err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
+ logger.addHandler(err_handler)
- logger = logging.getLogger()
- logger.addHandler(err_handler)
- logger.setLevel(logging.INFO if verbose else logging.WARNING)
-
- # Save the log file location for access by other components.
- hass.data[DATA_LOGGING] = err_log_path
- else:
- _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
+ # Save the log file location for access by other components.
+ hass.data[DATA_LOGGING] = err_log_path
+ else:
+ _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass)
diff --git a/homeassistant/brands/eltako.json b/homeassistant/brands/eltako.json
new file mode 100644
index 00000000000..ead922aa5b2
--- /dev/null
+++ b/homeassistant/brands/eltako.json
@@ -0,0 +1,5 @@
+{
+ "domain": "eltako",
+ "name": "Eltako",
+ "iot_standards": ["matter"]
+}
diff --git a/homeassistant/brands/fritzbox.json b/homeassistant/brands/fritzbox.json
index d0c0d1c1584..15c7d3a9119 100644
--- a/homeassistant/brands/fritzbox.json
+++ b/homeassistant/brands/fritzbox.json
@@ -1,5 +1,5 @@
{
"domain": "fritzbox",
- "name": "FRITZ!Box",
+ "name": "FRITZ!",
"integrations": ["fritz", "fritzbox", "fritzbox_callmonitor"]
}
diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json
index 2da0e2426f5..872cfc0aac5 100644
--- a/homeassistant/brands/google.json
+++ b/homeassistant/brands/google.json
@@ -6,7 +6,6 @@
"google_assistant_sdk",
"google_cloud",
"google_drive",
- "google_gemini",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
diff --git a/homeassistant/brands/ibm.json b/homeassistant/brands/ibm.json
deleted file mode 100644
index 42367e899e7..00000000000
--- a/homeassistant/brands/ibm.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "domain": "ibm",
- "name": "IBM",
- "integrations": ["watson_iot", "watson_tts"]
-}
diff --git a/homeassistant/brands/konnected.json b/homeassistant/brands/konnected.json
new file mode 100644
index 00000000000..6581fe1e476
--- /dev/null
+++ b/homeassistant/brands/konnected.json
@@ -0,0 +1,5 @@
+{
+ "domain": "konnected",
+ "name": "Konnected",
+ "integrations": ["konnected", "konnected_esphome"]
+}
diff --git a/homeassistant/brands/level.json b/homeassistant/brands/level.json
new file mode 100644
index 00000000000..89fe23b502b
--- /dev/null
+++ b/homeassistant/brands/level.json
@@ -0,0 +1,5 @@
+{
+ "domain": "level",
+ "name": "Level",
+ "iot_standards": ["matter"]
+}
diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py
index bd915b42408..9f29c844235 100644
--- a/homeassistant/components/acaia/coordinator.py
+++ b/homeassistant/components/acaia/coordinator.py
@@ -8,14 +8,17 @@ import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
+from homeassistant.components.bluetooth import async_get_scanner
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
+UPDATE_DEBOUNCE_TIME = 0.2
_LOGGER = logging.getLogger(__name__)
@@ -37,11 +40,20 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
config_entry=entry,
)
+ debouncer = Debouncer(
+ hass=hass,
+ logger=_LOGGER,
+ cooldown=UPDATE_DEBOUNCE_TIME,
+ immediate=True,
+ function=self.async_update_listeners,
+ )
+
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
- notify_callback=self.async_update_listeners,
+ notify_callback=debouncer.async_schedule_call,
+ scanner=async_get_scanner(hass),
)
@property
diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json
index f39511ad41a..4b2b3da9d75 100644
--- a/homeassistant/components/acaia/manifest.json
+++ b/homeassistant/components/acaia/manifest.json
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
- "requirements": ["aioacaia==0.1.14"]
+ "requirements": ["aioacaia==0.1.17"]
}
diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py
index c046933d5d5..bb453c67f57 100644
--- a/homeassistant/components/accuweather/__init__.py
+++ b/homeassistant/components/accuweather/__init__.py
@@ -2,21 +2,23 @@
from __future__ import annotations
+import asyncio
import logging
from accuweather import AccuWeather
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
-from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
+from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION
+from .const import DOMAIN
from .coordinator import (
AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData,
+ AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator,
)
@@ -28,7 +30,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool:
"""Set up AccuWeather as config entry."""
api_key: str = entry.data[CONF_API_KEY]
- name: str = entry.data[CONF_NAME]
location_key = entry.unique_id
@@ -41,26 +42,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
hass,
entry,
accuweather,
- name,
- "observation",
- UPDATE_INTERVAL_OBSERVATION,
)
-
coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator(
hass,
entry,
accuweather,
- name,
- "daily forecast",
- UPDATE_INTERVAL_DAILY_FORECAST,
+ )
+ coordinator_hourly_forecast = AccuWeatherHourlyForecastDataUpdateCoordinator(
+ hass,
+ entry,
+ accuweather,
)
- await coordinator_observation.async_config_entry_first_refresh()
- await coordinator_daily_forecast.async_config_entry_first_refresh()
+ await asyncio.gather(
+ coordinator_observation.async_config_entry_first_refresh(),
+ coordinator_daily_forecast.async_config_entry_first_refresh(),
+ coordinator_hourly_forecast.async_config_entry_first_refresh(),
+ )
entry.runtime_data = AccuWeatherData(
coordinator_observation=coordinator_observation,
coordinator_daily_forecast=coordinator_daily_forecast,
+ coordinator_hourly_forecast=coordinator_hourly_forecast,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py
index 3e65374f391..00c5f926456 100644
--- a/homeassistant/components/accuweather/config_flow.py
+++ b/homeassistant/components/accuweather/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from asyncio import timeout
+from collections.abc import Mapping
from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
@@ -22,6 +23,8 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for AccuWeather."""
VERSION = 1
+ _latitude: float | None = None
+ _longitude: float | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -50,6 +53,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
accuweather.location_key, raise_on_progress=False
)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
@@ -73,3 +77,46 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle configuration by re-auth."""
+ self._latitude = entry_data[CONF_LATITUDE]
+ self._longitude = entry_data[CONF_LONGITUDE]
+
+ 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."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ websession = async_get_clientsession(self.hass)
+ try:
+ async with timeout(10):
+ accuweather = AccuWeather(
+ user_input[CONF_API_KEY],
+ websession,
+ latitude=self._latitude,
+ longitude=self._longitude,
+ )
+ await accuweather.async_get_location()
+ except (ApiError, ClientConnectorError, TimeoutError, ClientError):
+ errors["base"] = "cannot_connect"
+ except InvalidApiKeyError:
+ errors["base"] = "invalid_api_key"
+ except RequestsExceededError:
+ errors["base"] = "requests_exceeded"
+ else:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data_updates=user_input
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ errors=errors,
+ )
diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py
index e1dc4a9abcb..a487e95582c 100644
--- a/homeassistant/components/accuweather/const.py
+++ b/homeassistant/components/accuweather/const.py
@@ -69,5 +69,6 @@ POLLEN_CATEGORY_MAP = {
4: "very_high",
5: "extreme",
}
-UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
+UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
+UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)
diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py
index 780c977f930..3c4991d2c59 100644
--- a/homeassistant/components/accuweather/coordinator.py
+++ b/homeassistant/components/accuweather/coordinator.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from asyncio import timeout
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -12,7 +13,9 @@ from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExcee
from aiohttp.client_exceptions import ClientConnectorError
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
@@ -20,9 +23,15 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
-from .const import DOMAIN, MANUFACTURER
+from .const import (
+ DOMAIN,
+ MANUFACTURER,
+ UPDATE_INTERVAL_DAILY_FORECAST,
+ UPDATE_INTERVAL_HOURLY_FORECAST,
+ UPDATE_INTERVAL_OBSERVATION,
+)
-EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
+EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError)
_LOGGER = logging.getLogger(__name__)
@@ -33,6 +42,7 @@ class AccuWeatherData:
coordinator_observation: AccuWeatherObservationDataUpdateCoordinator
coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator
+ coordinator_hourly_forecast: AccuWeatherHourlyForecastDataUpdateCoordinator
type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData]
@@ -43,18 +53,18 @@ class AccuWeatherObservationDataUpdateCoordinator(
):
"""Class to manage fetching AccuWeather data API."""
+ config_entry: AccuWeatherConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
- name: str,
- coordinator_type: str,
- update_interval: timedelta,
) -> None:
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
+ name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -65,8 +75,8 @@ class AccuWeatherObservationDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
- name=f"{name} ({coordinator_type})",
- update_interval=update_interval,
+ name=f"{name} (observation)",
+ update_interval=UPDATE_INTERVAL_OBSERVATION,
)
async def _async_update_data(self) -> dict[str, Any]:
@@ -80,29 +90,39 @@ class AccuWeatherObservationDataUpdateCoordinator(
translation_key="current_conditions_update_error",
translation_placeholders={"error": repr(error)},
) from error
+ except InvalidApiKeyError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"entry": self.config_entry.title},
+ ) from err
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
return result
-class AccuWeatherDailyForecastDataUpdateCoordinator(
+class AccuWeatherForecastDataUpdateCoordinator(
TimestampDataUpdateCoordinator[list[dict[str, Any]]]
):
- """Class to manage fetching AccuWeather data API."""
+ """Base class for AccuWeather forecast."""
+
+ config_entry: AccuWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
- name: str,
coordinator_type: str,
update_interval: timedelta,
+ fetch_method: Callable[..., Awaitable[list[dict[str, Any]]]],
) -> None:
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
+ self._fetch_method = fetch_method
+ name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -118,24 +138,71 @@ class AccuWeatherDailyForecastDataUpdateCoordinator(
)
async def _async_update_data(self) -> list[dict[str, Any]]:
- """Update data via library."""
+ """Update forecast data via library."""
try:
async with timeout(10):
- result = await self.accuweather.async_get_daily_forecast(
- language=self.hass.config.language
- )
+ result = await self._fetch_method(language=self.hass.config.language)
except EXCEPTIONS as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="forecast_update_error",
translation_placeholders={"error": repr(error)},
) from error
+ except InvalidApiKeyError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"entry": self.config_entry.title},
+ ) from err
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
-
return result
+class AccuWeatherDailyForecastDataUpdateCoordinator(
+ AccuWeatherForecastDataUpdateCoordinator
+):
+ """Coordinator for daily forecast."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: AccuWeatherConfigEntry,
+ accuweather: AccuWeather,
+ ) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ config_entry,
+ accuweather,
+ "daily forecast",
+ UPDATE_INTERVAL_DAILY_FORECAST,
+ fetch_method=accuweather.async_get_daily_forecast,
+ )
+
+
+class AccuWeatherHourlyForecastDataUpdateCoordinator(
+ AccuWeatherForecastDataUpdateCoordinator
+):
+ """Coordinator for hourly forecast."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: AccuWeatherConfigEntry,
+ accuweather: AccuWeather,
+ ) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ config_entry,
+ accuweather,
+ "hourly forecast",
+ UPDATE_INTERVAL_HOURLY_FORECAST,
+ fetch_method=accuweather.async_get_hourly_forecast,
+ )
+
+
def _get_device_info(location_key: str, name: str) -> DeviceInfo:
"""Get device info."""
return DeviceInfo(
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index 810557519eb..11f927c6aeb 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
- "requirements": ["accuweather==4.2.0"],
- "single_config_entry": true
+ "requirements": ["accuweather==4.2.2"]
}
diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json
index 19e52be1ce3..b46393acf78 100644
--- a/homeassistant/components/accuweather/strings.json
+++ b/homeassistant/components/accuweather/strings.json
@@ -7,6 +7,17 @@
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
+ },
+ "data_description": {
+ "api_key": "API key generated in the AccuWeather APIs portal."
+ }
+ },
+ "reauth_confirm": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
}
}
},
@@ -17,6 +28,10 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
@@ -236,6 +251,9 @@
}
},
"exceptions": {
+ "auth_error": {
+ "message": "Authentication failed for {entry}, please update your API key"
+ },
"current_conditions_update_error": {
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
},
diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py
index 770f2b64f20..25d6297cee6 100644
--- a/homeassistant/components/accuweather/weather.py
+++ b/homeassistant/components/accuweather/weather.py
@@ -45,6 +45,7 @@ from .coordinator import (
AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData,
+ AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator,
)
@@ -64,6 +65,7 @@ class AccuWeatherEntity(
CoordinatorWeatherEntity[
AccuWeatherObservationDataUpdateCoordinator,
AccuWeatherDailyForecastDataUpdateCoordinator,
+ AccuWeatherHourlyForecastDataUpdateCoordinator,
]
):
"""Define an AccuWeather entity."""
@@ -76,6 +78,7 @@ class AccuWeatherEntity(
super().__init__(
observation_coordinator=accuweather_data.coordinator_observation,
daily_coordinator=accuweather_data.coordinator_daily_forecast,
+ hourly_coordinator=accuweather_data.coordinator_hourly_forecast,
)
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
@@ -86,10 +89,13 @@ class AccuWeatherEntity(
self._attr_unique_id = accuweather_data.coordinator_observation.location_key
self._attr_attribution = ATTRIBUTION
self._attr_device_info = accuweather_data.coordinator_observation.device_info
- self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
+ self._attr_supported_features = (
+ WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
+ )
self.observation_coordinator = accuweather_data.coordinator_observation
self.daily_coordinator = accuweather_data.coordinator_daily_forecast
+ self.hourly_coordinator = accuweather_data.coordinator_hourly_forecast
@property
def condition(self) -> str | None:
@@ -207,3 +213,32 @@ class AccuWeatherEntity(
}
for item in self.daily_coordinator.data
]
+
+ @callback
+ def _async_forecast_hourly(self) -> list[Forecast] | None:
+ """Return the hourly forecast in native units."""
+ return [
+ {
+ ATTR_FORECAST_TIME: utc_from_timestamp(
+ item["EpochDateTime"]
+ ).isoformat(),
+ ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCover"],
+ ATTR_FORECAST_HUMIDITY: item["RelativeHumidity"],
+ ATTR_FORECAST_NATIVE_TEMP: item["Temperature"][ATTR_VALUE],
+ ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperature"][
+ ATTR_VALUE
+ ],
+ ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquid"][ATTR_VALUE],
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[
+ "PrecipitationProbability"
+ ],
+ ATTR_FORECAST_NATIVE_WIND_SPEED: item["Wind"][ATTR_SPEED][ATTR_VALUE],
+ ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGust"][ATTR_SPEED][
+ ATTR_VALUE
+ ],
+ ATTR_FORECAST_UV_INDEX: item["UVIndex"],
+ ATTR_FORECAST_WIND_BEARING: item["Wind"][ATTR_DIRECTION]["Degrees"],
+ ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["WeatherIcon"]),
+ }
+ for item in self.hourly_coordinator.data
+ ]
diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py
index a16e11c05d7..767104916bf 100644
--- a/homeassistant/components/ai_task/__init__.py
+++ b/homeassistant/components/ai_task/__init__.py
@@ -29,11 +29,19 @@ from .const import (
DATA_PREFERENCES,
DOMAIN,
SERVICE_GENERATE_DATA,
+ SERVICE_GENERATE_IMAGE,
AITaskEntityFeature,
)
from .entity import AITaskEntity
from .http import async_setup as async_setup_http
-from .task import GenDataTask, GenDataTaskResult, async_generate_data
+from .task import (
+ GenDataTask,
+ GenDataTaskResult,
+ GenImageTask,
+ GenImageTaskResult,
+ async_generate_data,
+ async_generate_image,
+)
__all__ = [
"DOMAIN",
@@ -41,7 +49,10 @@ __all__ = [
"AITaskEntityFeature",
"GenDataTask",
"GenDataTaskResult",
+ "GenImageTask",
+ "GenImageTaskResult",
"async_generate_data",
+ "async_generate_image",
"async_setup",
"async_setup_entry",
"async_unload_entry",
@@ -101,6 +112,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
supports_response=SupportsResponse.ONLY,
job_type=HassJobType.Coroutinefunction,
)
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GENERATE_IMAGE,
+ async_service_generate_image,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_TASK_NAME): cv.string,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_INSTRUCTIONS): cv.string,
+ vol.Optional(ATTR_ATTACHMENTS): vol.All(
+ cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
+ ),
+ }
+ ),
+ supports_response=SupportsResponse.ONLY,
+ job_type=HassJobType.Coroutinefunction,
+ )
return True
@@ -115,17 +143,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
- """Run the run task service."""
+ """Run the data task service."""
result = await async_generate_data(hass=call.hass, **call.data)
return result.as_dict()
+async def async_service_generate_image(call: ServiceCall) -> ServiceResponse:
+ """Run the image task service."""
+ return await async_generate_image(hass=call.hass, **call.data)
+
+
class AITaskPreferences:
"""AI Task preferences."""
- KEYS = ("gen_data_entity_id",)
+ KEYS = ("gen_data_entity_id", "gen_image_entity_id")
gen_data_entity_id: str | None = None
+ gen_image_entity_id: str | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the preferences."""
@@ -139,17 +173,21 @@ class AITaskPreferences:
if data is None:
return
for key in self.KEYS:
- setattr(self, key, data[key])
+ setattr(self, key, data.get(key))
@callback
def async_set_preferences(
self,
*,
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
+ gen_image_entity_id: str | None | UndefinedType = UNDEFINED,
) -> None:
"""Set the preferences."""
changed = False
- for key, value in (("gen_data_entity_id", gen_data_entity_id),):
+ for key, value in (
+ ("gen_data_entity_id", gen_data_entity_id),
+ ("gen_image_entity_id", gen_image_entity_id),
+ ):
if value is not UNDEFINED:
if getattr(self, key) != value:
setattr(self, key, value)
diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py
index 09948e9b673..978e6f3cfb9 100644
--- a/homeassistant/components/ai_task/const.py
+++ b/homeassistant/components/ai_task/const.py
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Final
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
+ from homeassistant.components.media_source import local_source
from homeassistant.helpers.entity_component import EntityComponent
from . import AITaskPreferences
@@ -16,8 +17,13 @@ if TYPE_CHECKING:
DOMAIN = "ai_task"
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
+DATA_MEDIA_SOURCE: HassKey[local_source.LocalSource] = HassKey(f"{DOMAIN}_media_source")
+
+IMAGE_DIR: Final = "image"
+IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour
SERVICE_GENERATE_DATA = "generate_data"
+SERVICE_GENERATE_IMAGE = "generate_image"
ATTR_INSTRUCTIONS: Final = "instructions"
ATTR_TASK_NAME: Final = "task_name"
@@ -38,3 +44,6 @@ class AITaskEntityFeature(IntFlag):
SUPPORT_ATTACHMENTS = 2
"""Support attachments with generate data."""
+
+ GENERATE_IMAGE = 4
+ """Generate images based on instructions."""
diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py
index 4c5cd186943..aea776b2100 100644
--- a/homeassistant/components/ai_task/entity.py
+++ b/homeassistant/components/ai_task/entity.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
-from .task import GenDataTask, GenDataTaskResult
+from .task import GenDataTask, GenDataTaskResult, GenImageTask, GenImageTaskResult
class AITaskEntity(RestoreEntity):
@@ -57,9 +57,13 @@ class AITaskEntity(RestoreEntity):
async def _async_get_ai_task_chat_log(
self,
session: ChatSession,
- task: GenDataTask,
+ task: GenDataTask | GenImageTask,
) -> AsyncGenerator[ChatLog]:
"""Context manager used to manage the ChatLog used during an AI Task."""
+ user_llm_hass_api: llm.API | None = None
+ if isinstance(task, GenDataTask):
+ user_llm_hass_api = task.llm_api
+
# pylint: disable-next=contextmanager-generator-missing-cleanup
with (
async_get_chat_log(
@@ -77,6 +81,7 @@ class AITaskEntity(RestoreEntity):
device_id=None,
),
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
+ user_llm_hass_api=user_llm_hass_api,
)
chat_log.async_add_user_content(
@@ -104,3 +109,23 @@ class AITaskEntity(RestoreEntity):
) -> GenDataTaskResult:
"""Handle a gen data task."""
raise NotImplementedError
+
+ @final
+ async def internal_async_generate_image(
+ self,
+ session: ChatSession,
+ task: GenImageTask,
+ ) -> GenImageTaskResult:
+ """Run a gen image task."""
+ self.__last_activity = dt_util.utcnow().isoformat()
+ self.async_write_ha_state()
+ async with self._async_get_ai_task_chat_log(session, task) as chat_log:
+ return await self._async_generate_image(task, chat_log)
+
+ async def _async_generate_image(
+ self,
+ task: GenImageTask,
+ chat_log: ChatLog,
+ ) -> GenImageTaskResult:
+ """Handle a gen image task."""
+ raise NotImplementedError
diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py
index 5deffa84008..ba6aa63415b 100644
--- a/homeassistant/components/ai_task/http.py
+++ b/homeassistant/components/ai_task/http.py
@@ -37,6 +37,7 @@ def websocket_get_preferences(
{
vol.Required("type"): "ai_task/preferences/set",
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
+ vol.Optional("gen_image_entity_id"): vol.Any(str, None),
}
)
@websocket_api.require_admin
diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json
index 4a875e9fb11..2765402abf8 100644
--- a/homeassistant/components/ai_task/icons.json
+++ b/homeassistant/components/ai_task/icons.json
@@ -1,7 +1,15 @@
{
+ "entity_component": {
+ "_": {
+ "default": "mdi:star-four-points"
+ }
+ },
"services": {
"generate_data": {
"service": "mdi:file-star-four-points-outline"
+ },
+ "generate_image": {
+ "service": "mdi:star-four-points-box-outline"
}
}
}
diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json
index ea377ffa671..d05faf18055 100644
--- a/homeassistant/components/ai_task/manifest.json
+++ b/homeassistant/components/ai_task/manifest.json
@@ -5,6 +5,6 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["conversation", "media_source"],
"documentation": "https://www.home-assistant.io/integrations/ai_task",
- "integration_type": "system",
+ "integration_type": "entity",
"quality_scale": "internal"
}
diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py
new file mode 100644
index 00000000000..61a212be5b0
--- /dev/null
+++ b/homeassistant/components/ai_task/media_source.py
@@ -0,0 +1,32 @@
+"""Expose images as media sources."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from homeassistant.components.media_source import MediaSource, local_source
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
+
+
+async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
+ """Set up local media source."""
+ media_dirs = list(hass.config.media_dirs.values())
+
+ if not media_dirs:
+ raise HomeAssistantError(
+ "AI Task media source requires at least one media directory configured"
+ )
+
+ media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
+
+ hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
+ hass,
+ DOMAIN,
+ "AI Generated Images",
+ {IMAGE_DIR: str(media_dir)},
+ f"/{DOMAIN}",
+ )
+ return source
diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml
index feefa70a30b..8a37990a5d7 100644
--- a/homeassistant/components/ai_task/services.yaml
+++ b/homeassistant/components/ai_task/services.yaml
@@ -20,7 +20,6 @@ generate_data:
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA
structure:
- advanced: true
required: false
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
selector:
@@ -31,3 +30,30 @@ generate_data:
media:
accept:
- "*"
+generate_image:
+ fields:
+ task_name:
+ example: "picture of a dog"
+ required: true
+ selector:
+ text:
+ instructions:
+ example: "Generate a high quality square image of a dog on transparent background"
+ required: true
+ selector:
+ text:
+ multiline: true
+ entity_id:
+ required: true
+ selector:
+ entity:
+ filter:
+ domain: ai_task
+ supported_features:
+ - ai_task.AITaskEntityFeature.GENERATE_IMAGE
+ attachments:
+ required: false
+ selector:
+ media:
+ accept:
+ - "*"
diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json
index 261381b7c31..3ec366afb0d 100644
--- a/homeassistant/components/ai_task/strings.json
+++ b/homeassistant/components/ai_task/strings.json
@@ -25,6 +25,28 @@
"description": "List of files to attach for multi-modal AI analysis."
}
}
+ },
+ "generate_image": {
+ "name": "Generate image",
+ "description": "Uses AI to generate image.",
+ "fields": {
+ "task_name": {
+ "name": "Task name",
+ "description": "Name of the task."
+ },
+ "instructions": {
+ "name": "Instructions",
+ "description": "Instructions that explains the image to be generated."
+ },
+ "entity_id": {
+ "name": "Entity ID",
+ "description": "Entity ID to run the task on."
+ },
+ "attachments": {
+ "name": "Attachments",
+ "description": "List of files to attach for using as references."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py
index 3cc43f8c07a..1d27f75b6c7 100644
--- a/homeassistant/components/ai_task/task.py
+++ b/homeassistant/components/ai_task/task.py
@@ -3,6 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import datetime, timedelta
+import io
import mimetypes
from pathlib import Path
import tempfile
@@ -10,85 +12,73 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components import camera, conversation, media_source
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.components import camera, conversation, image, media_source
+from homeassistant.components.http.auth import async_sign_path
+from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.chat_session import async_get_chat_session
+from homeassistant.helpers import llm
+from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
+from homeassistant.util import RE_SANITIZE_FILENAME, slugify
-from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
+from .const import (
+ DATA_COMPONENT,
+ DATA_MEDIA_SOURCE,
+ DATA_PREFERENCES,
+ DOMAIN,
+ IMAGE_DIR,
+ IMAGE_EXPIRY_TIME,
+ AITaskEntityFeature,
+)
-def _save_camera_snapshot(image: camera.Image) -> Path:
+def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path:
"""Save camera snapshot to temp file."""
with tempfile.NamedTemporaryFile(
mode="wb",
- suffix=mimetypes.guess_extension(image.content_type, False),
+ suffix=mimetypes.guess_extension(image_data.content_type, False),
delete=False,
) as temp_file:
- temp_file.write(image.content)
+ temp_file.write(image_data.content)
return Path(temp_file.name)
-async def async_generate_data(
+async def _resolve_attachments(
hass: HomeAssistant,
- *,
- task_name: str,
- entity_id: str | None = None,
- instructions: str,
- structure: vol.Schema | None = None,
+ session: ChatSession,
attachments: list[dict] | None = None,
-) -> GenDataTaskResult:
- """Run a task in the AI Task integration."""
- if entity_id is None:
- entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
-
- if entity_id is None:
- raise HomeAssistantError("No entity_id provided and no preferred entity set")
-
- entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
- if entity is None:
- raise HomeAssistantError(f"AI Task entity {entity_id} not found")
-
- if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
- raise HomeAssistantError(
- f"AI Task entity {entity_id} does not support generating data"
- )
-
- # Resolve attachments
+) -> list[conversation.Attachment]:
+ """Resolve attachments for a task."""
resolved_attachments: list[conversation.Attachment] = []
created_files: list[Path] = []
- if (
- attachments
- and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
- ):
- raise HomeAssistantError(
- f"AI Task entity {entity_id} does not support attachments"
- )
-
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
- # Special case for camera media sources
- if media_content_id.startswith("media-source://camera/"):
- # Extract entity_id from the media content ID
- entity_id = media_content_id.removeprefix("media-source://camera/")
+ # Special case for certain media sources
+ for integration in camera, image:
+ media_source_prefix = f"media-source://{integration.DOMAIN}/"
+ if not media_content_id.startswith(media_source_prefix):
+ continue
- # Get snapshot from camera
- image = await camera.async_get_image(hass, entity_id)
+ # Extract entity_id from the media content ID
+ entity_id = media_content_id.removeprefix(media_source_prefix)
+
+ # Get snapshot from entity
+ image_data = await integration.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
- _save_camera_snapshot, image
+ _save_camera_snapshot, image_data
)
created_files.append(temp_filename)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
- mime_type=image.content_type,
+ mime_type=image_data.content_type,
path=temp_filename,
)
)
+ break
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)
@@ -104,20 +94,60 @@ async def async_generate_data(
)
)
+ if not created_files:
+ return resolved_attachments
+
+ def cleanup_files() -> None:
+ """Cleanup temporary files."""
+ for file in created_files:
+ file.unlink(missing_ok=True)
+
+ @callback
+ def cleanup_files_callback() -> None:
+ """Cleanup temporary files."""
+ hass.async_add_executor_job(cleanup_files)
+
+ session.async_on_cleanup(cleanup_files_callback)
+
+ return resolved_attachments
+
+
+async def async_generate_data(
+ hass: HomeAssistant,
+ *,
+ task_name: str,
+ entity_id: str | None = None,
+ instructions: str,
+ structure: vol.Schema | None = None,
+ attachments: list[dict] | None = None,
+ llm_api: llm.API | None = None,
+) -> GenDataTaskResult:
+ """Run a data generation task in the AI Task integration."""
+ if entity_id is None:
+ entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
+
+ if entity_id is None:
+ raise HomeAssistantError("No entity_id provided and no preferred entity set")
+
+ entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
+ if entity is None:
+ raise HomeAssistantError(f"AI Task entity {entity_id} not found")
+
+ if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
+ raise HomeAssistantError(
+ f"AI Task entity {entity_id} does not support generating data"
+ )
+
+ if (
+ attachments
+ and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
+ ):
+ raise HomeAssistantError(
+ f"AI Task entity {entity_id} does not support attachments"
+ )
+
with async_get_chat_session(hass) as session:
- if created_files:
-
- def cleanup_files() -> None:
- """Cleanup temporary files."""
- for file in created_files:
- file.unlink(missing_ok=True)
-
- @callback
- def cleanup_files_callback() -> None:
- """Cleanup temporary files."""
- hass.async_add_executor_job(cleanup_files)
-
- session.async_on_cleanup(cleanup_files_callback)
+ resolved_attachments = await _resolve_attachments(hass, session, attachments)
return await entity.internal_async_generate_data(
session,
@@ -126,10 +156,92 @@ async def async_generate_data(
instructions=instructions,
structure=structure,
attachments=resolved_attachments or None,
+ llm_api=llm_api,
),
)
+async def async_generate_image(
+ hass: HomeAssistant,
+ *,
+ task_name: str,
+ entity_id: str | None = None,
+ instructions: str,
+ attachments: list[dict] | None = None,
+) -> ServiceResponse:
+ """Run an image generation task in the AI Task integration."""
+ if entity_id is None:
+ entity_id = hass.data[DATA_PREFERENCES].gen_image_entity_id
+
+ if entity_id is None:
+ raise HomeAssistantError("No entity_id provided and no preferred entity set")
+
+ entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
+ if entity is None:
+ raise HomeAssistantError(f"AI Task entity {entity_id} not found")
+
+ if AITaskEntityFeature.GENERATE_IMAGE not in entity.supported_features:
+ raise HomeAssistantError(
+ f"AI Task entity {entity_id} does not support generating images"
+ )
+
+ if (
+ attachments
+ and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
+ ):
+ raise HomeAssistantError(
+ f"AI Task entity {entity_id} does not support attachments"
+ )
+
+ with async_get_chat_session(hass) as session:
+ resolved_attachments = await _resolve_attachments(hass, session, attachments)
+
+ task_result = await entity.internal_async_generate_image(
+ session,
+ GenImageTask(
+ name=task_name,
+ instructions=instructions,
+ attachments=resolved_attachments or None,
+ ),
+ )
+
+ service_result = task_result.as_dict()
+ image_data = service_result.pop("image_data")
+ if service_result.get("revised_prompt") is None:
+ service_result["revised_prompt"] = instructions
+
+ source = hass.data[DATA_MEDIA_SOURCE]
+
+ current_time = datetime.now()
+ ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
+ sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
+
+ image_file = ImageData(
+ filename=f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}",
+ file=io.BytesIO(image_data),
+ content_type=task_result.mime_type,
+ )
+
+ target_folder = media_source.MediaSourceItem.from_uri(
+ hass, f"media-source://{DOMAIN}/{IMAGE_DIR}", None
+ )
+
+ service_result["media_source_id"] = await source.async_upload_media(
+ target_folder, image_file
+ )
+
+ item = media_source.MediaSourceItem.from_uri(
+ hass, service_result["media_source_id"], None
+ )
+ service_result["url"] = async_sign_path(
+ hass,
+ (await source.async_resolve_media(item)).url,
+ timedelta(seconds=IMAGE_EXPIRY_TIME),
+ )
+
+ return service_result
+
+
@dataclass(slots=True)
class GenDataTask:
"""Gen data task to be processed."""
@@ -146,6 +258,9 @@ class GenDataTask:
attachments: list[conversation.Attachment] | None = None
"""List of attachments to go along the instructions."""
+ llm_api: llm.API | None = None
+ """API to provide to the LLM."""
+
def __str__(self) -> str:
"""Return task as a string."""
return f""
@@ -167,3 +282,68 @@ class GenDataTaskResult:
"conversation_id": self.conversation_id,
"data": self.data,
}
+
+
+@dataclass(slots=True)
+class GenImageTask:
+ """Gen image task to be processed."""
+
+ name: str
+ """Name of the task."""
+
+ instructions: str
+ """Instructions on what needs to be done."""
+
+ attachments: list[conversation.Attachment] | None = None
+ """List of attachments to go along the instructions."""
+
+ def __str__(self) -> str:
+ """Return task as a string."""
+ return f""
+
+
+@dataclass(slots=True)
+class GenImageTaskResult:
+ """Result of gen image task."""
+
+ image_data: bytes
+ """Raw image data generated by the model."""
+
+ conversation_id: str
+ """Unique identifier for the conversation."""
+
+ mime_type: str
+ """MIME type of the generated image."""
+
+ width: int | None = None
+ """Width of the generated image, if available."""
+
+ height: int | None = None
+ """Height of the generated image, if available."""
+
+ model: str | None = None
+ """Model used to generate the image, if available."""
+
+ revised_prompt: str | None = None
+ """Revised prompt used to generate the image, if applicable."""
+
+ def as_dict(self) -> dict[str, Any]:
+ """Return result as a dict."""
+ return {
+ "image_data": self.image_data,
+ "conversation_id": self.conversation_id,
+ "mime_type": self.mime_type,
+ "width": self.width,
+ "height": self.height,
+ "model": self.model,
+ "revised_prompt": self.revised_prompt,
+ }
+
+
+@dataclass(slots=True)
+class ImageData:
+ """Implementation of media_source.local_source.UploadedFile protocol."""
+
+ filename: str
+ file: io.IOBase
+ content_type: str
diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py
index ea184e5613d..9eea047f9b7 100644
--- a/homeassistant/components/airos/__init__.py
+++ b/homeassistant/components/airos/__init__.py
@@ -2,12 +2,20 @@
from __future__ import annotations
-from airos.airos8 import AirOS
+from airos.airos8 import AirOS8
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+ Platform,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [
@@ -21,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
- session = async_get_clientsession(hass, verify_ssl=False)
+ session = async_get_clientsession(
+ hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
+ )
- airos_device = AirOS(
+ airos_device = AirOS8(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
+ use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
@@ -40,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
return True
+async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
+ """Migrate old config entry."""
+
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if entry.version == 1 and entry.minor_version == 1:
+ new_data = {**entry.data}
+ advanced_data = {
+ CONF_SSL: DEFAULT_SSL,
+ CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
+ }
+ new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
+
+ hass.config_entries.async_update_entry(
+ entry,
+ data=new_data,
+ minor_version=2,
+ )
+
+ return True
+
+
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py
index e743cda4c63..1fc89d5301a 100644
--- a/homeassistant/components/airos/binary_sensor.py
+++ b/homeassistant/components/airos/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
+from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor."""
- value_fn: Callable[[AirOSData], bool]
+ value_fn: Callable[[AirOS8Data], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py
index 8df93c7b2c4..fac4ccef804 100644
--- a/homeassistant/components/airos/config_flow.py
+++ b/homeassistant/components/airos/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Mapping
import logging
from typing import Any
@@ -14,12 +15,24 @@ from airos.exceptions import (
)
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
-from .const import DOMAIN
-from .coordinator import AirOS
+from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
+from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__)
@@ -28,6 +41,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str,
+ vol.Required(SECTION_ADVANCED_SETTINGS): section(
+ vol.Schema(
+ {
+ vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
+ vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+ }
+ ),
+ {"collapsed": True},
+ ),
}
)
@@ -36,47 +58,109 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
VERSION = 1
+ MINOR_VERSION = 2
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ super().__init__()
+ self.airos_device: AirOS8
+ self.errors: dict[str, str] = {}
async def async_step_user(
- self,
- user_input: dict[str, Any] | None = None,
+ self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the initial step."""
- errors: dict[str, str] = {}
+ """Handle the manual input of host and credentials."""
+ self.errors = {}
if user_input is not None:
- # By default airOS 8 comes with self-signed SSL certificates,
- # with no option in the web UI to change or upload a custom certificate.
- session = async_get_clientsession(self.hass, verify_ssl=False)
-
- airos_device = AirOS(
- host=user_input[CONF_HOST],
- username=user_input[CONF_USERNAME],
- password=user_input[CONF_PASSWORD],
- session=session,
- )
- try:
- await airos_device.login()
- airos_data = await airos_device.status()
-
- except (
- AirOSConnectionSetupError,
- AirOSDeviceConnectionError,
- ):
- errors["base"] = "cannot_connect"
- except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
- errors["base"] = "invalid_auth"
- except AirOSKeyDataMissingError:
- errors["base"] = "key_data_missing"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- else:
- await self.async_set_unique_id(airos_data.derived.mac)
- self._abort_if_unique_id_configured()
+ validated_info = await self._validate_and_get_device_info(user_input)
+ if validated_info:
return self.async_create_entry(
- title=airos_data.host.hostname, data=user_input
+ title=validated_info["title"],
+ data=validated_info["data"],
+ )
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
+ )
+
+ async def _validate_and_get_device_info(
+ self, config_data: dict[str, Any]
+ ) -> dict[str, Any] | None:
+ """Validate user input with the device API."""
+ # By default airOS 8 comes with self-signed SSL certificates,
+ # with no option in the web UI to change or upload a custom certificate.
+ session = async_get_clientsession(
+ self.hass,
+ verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
+ )
+
+ airos_device = AirOS8(
+ host=config_data[CONF_HOST],
+ username=config_data[CONF_USERNAME],
+ password=config_data[CONF_PASSWORD],
+ session=session,
+ use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
+ )
+ try:
+ await airos_device.login()
+ airos_data = await airos_device.status()
+
+ except (
+ AirOSConnectionSetupError,
+ AirOSDeviceConnectionError,
+ ):
+ self.errors["base"] = "cannot_connect"
+ except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
+ self.errors["base"] = "invalid_auth"
+ except AirOSKeyDataMissingError:
+ self.errors["base"] = "key_data_missing"
+ except Exception:
+ _LOGGER.exception("Unexpected exception during credential validation")
+ self.errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(airos_data.derived.mac)
+
+ if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch()
+ else:
+ self._abort_if_unique_id_configured()
+
+ return {"title": airos_data.host.hostname, "data": config_data}
+
+ return None
+
+ async def async_step_reauth(
+ self,
+ user_input: Mapping[str, Any],
+ ) -> ConfigFlowResult:
+ """Perform reauthentication upon an API authentication error."""
+ return await self.async_step_reauth_confirm(user_input)
+
+ async def async_step_reauth_confirm(
+ self,
+ user_input: Mapping[str, Any],
+ ) -> ConfigFlowResult:
+ """Perform reauthentication upon an API authentication error."""
+ self.errors = {}
+
+ if user_input:
+ validate_data = {**self._get_reauth_entry().data, **user_input}
+ if await self._validate_and_get_device_info(config_data=validate_data):
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates=validate_data,
)
return self.async_show_form(
- step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ step_id="reauth_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ }
+ ),
+ errors=self.errors,
)
diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py
index f4be2594613..29a5f6a9e55 100644
--- a/homeassistant/components/airos/const.py
+++ b/homeassistant/components/airos/const.py
@@ -7,3 +7,8 @@ DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti"
+
+DEFAULT_VERIFY_SSL = False
+DEFAULT_SSL = True
+
+SECTION_ADVANCED_SETTINGS = "advanced_settings"
diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py
index 2fe675ee76a..b1f9a770c0a 100644
--- a/homeassistant/components/airos/coordinator.py
+++ b/homeassistant/components/airos/coordinator.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from airos.airos8 import AirOS, AirOSData
+from airos.airos8 import AirOS8, AirOS8Data
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
@@ -14,7 +14,7 @@ from airos.exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
@@ -24,13 +24,13 @@ _LOGGER = logging.getLogger(__name__)
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
-class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
+class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
"""Class to manage fetching AirOS data from single endpoint."""
config_entry: AirOSConfigEntry
def __init__(
- self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
+ self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
@@ -42,14 +42,14 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
update_interval=SCAN_INTERVAL,
)
- async def _async_update_data(self) -> AirOSData:
+ async def _async_update_data(self) -> AirOS8Data:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
return await self.airos_device.status()
- except (AirOSConnectionAuthenticationError,) as err:
+ except AirOSConnectionAuthenticationError as err:
_LOGGER.exception("Error authenticating with airOS device")
- raise ConfigEntryError(
+ raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (
diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py
index e54962110fc..0b1245694c1 100644
--- a/homeassistant/components/airos/entity.py
+++ b/homeassistant/components/airos/entity.py
@@ -2,11 +2,11 @@
from __future__ import annotations
-from homeassistant.const import CONF_HOST
+from homeassistant.const import CONF_HOST, CONF_SSL
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN, MANUFACTURER
+from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSDataUpdateCoordinator
@@ -20,9 +20,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
super().__init__(coordinator)
airos_data = self.coordinator.data
+ url_schema = (
+ "https"
+ if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
+ else "http"
+ )
configuration_url: str | None = (
- f"https://{coordinator.config_entry.data[CONF_HOST]}"
+ f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}"
)
self._attr_device_info = DeviceInfo(
diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json
index 2a2a241aef0..02a1ca997fb 100644
--- a/homeassistant/components/airos/manifest.json
+++ b/homeassistant/components/airos/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["airos==0.4.3"]
+ "requirements": ["airos==0.5.5"]
}
diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py
index 06b06a21e28..63c7f8d1e2e 100644
--- a/homeassistant/components/airos/sensor.py
+++ b/homeassistant/components/airos/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
+from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ PARALLEL_UPDATES = 0
class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor."""
- value_fn: Callable[[AirOSData], StateType]
+ value_fn: Callable[[AirOS8Data], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json
index 53681292f50..8630ee8c7af 100644
--- a/homeassistant/components/airos/strings.json
+++ b/homeassistant/components/airos/strings.json
@@ -2,6 +2,14 @@
"config": {
"flow_title": "Ubiquiti airOS device",
"step": {
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::airos::config::step::user::data_description::password%]"
+ }
+ },
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -12,6 +20,18 @@
"host": "IP address or hostname of the airOS device",
"username": "Administrator username for the airOS device, normally 'ubnt'",
"password": "Password configured through the UISP app or web interface"
+ },
+ "sections": {
+ "advanced_settings": {
+ "data": {
+ "ssl": "Use HTTPS",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "ssl": "Whether the connection should be encrypted (required for most devices)",
+ "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
+ }
+ }
}
}
},
@@ -22,7 +42,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
}
},
"entity": {
diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py
index 23711b7a9a2..42e21b28467 100644
--- a/homeassistant/components/airthings/config_flow.py
+++ b/homeassistant/components/airthings/config_flow.py
@@ -23,6 +23,10 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
+URL_API_INTEGRATION = {
+ "url": "https://dashboard.airthings.com/integrations/api-integration"
+}
+
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings."""
@@ -37,11 +41,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
- description_placeholders={
- "url": (
- "https://dashboard.airthings.com/integrations/api-integration"
- ),
- },
+ description_placeholders=URL_API_INTEGRATION,
)
errors = {}
@@ -65,5 +65,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form(
- step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ description_placeholders=URL_API_INTEGRATION,
)
diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json
index 610891fff10..2994c25ed43 100644
--- a/homeassistant/components/airthings/strings.json
+++ b/homeassistant/components/airthings/strings.json
@@ -4,9 +4,9 @@
"user": {
"data": {
"id": "ID",
- "secret": "Secret",
- "description": "Login at {url} to find your credentials"
- }
+ "secret": "Secret"
+ },
+ "description": "Log in at {url} to find your credentials"
}
},
"error": {
diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py
index 2d32fa6e7df..6a6857d95b3 100644
--- a/homeassistant/components/airthings_ble/config_flow.py
+++ b/homeassistant/components/airthings_ble/config_flow.py
@@ -8,6 +8,7 @@ from typing import Any
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
from bleak import BleakError
+from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol
from homeassistant.components import bluetooth
@@ -44,7 +45,7 @@ def get_name(device: AirthingsDevice) -> str:
name = device.friendly_name()
if identifier := device.identifier:
- name += f" ({identifier})"
+ name += f" ({device.model.value}{identifier})"
return name
@@ -117,6 +118,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
+ if (
+ self._discovered_device is not None
+ and self._discovered_device.device.firmware.need_firmware_upgrade
+ ):
+ return self.async_abort(reason="firmware_upgrade_required")
+
return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={}
)
@@ -137,6 +144,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
discovery = self._discovered_devices[address]
+ if discovery.device.firmware.need_firmware_upgrade:
+ return self.async_abort(reason="firmware_upgrade_required")
+
self.context["title_placeholders"] = {
"name": discovery.name,
}
@@ -146,21 +156,27 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids(include_ignore=False)
+ devices: list[BluetoothServiceInfoBleak] = []
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
-
if MFCT_ID not in discovery_info.manufacturer_data:
continue
-
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
continue
+ devices.append(discovery_info)
+ for discovery_info in devices:
+ address = discovery_info.address
try:
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
- return self.async_abort(reason="cannot_connect")
+ _LOGGER.error(
+ "Error connecting to and getting data from %s",
+ discovery_info.address,
+ )
+ continue
except Exception:
_LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
@@ -171,7 +187,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_devices_found")
titles = {
- address: discovery.device.name
+ address: get_name(discovery.device)
for (address, discovery) in self._discovered_devices.items()
}
return self.async_show_form(
diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json
index fe2cc0eeb36..5ac0b27e26f 100644
--- a/homeassistant/components/airthings_ble/manifest.json
+++ b/homeassistant/components/airthings_ble/manifest.json
@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling",
- "requirements": ["airthings-ble==0.9.2"]
+ "requirements": ["airthings-ble==1.1.1"]
}
diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py
index 9c1a2af7a9f..ee94052c286 100644
--- a/homeassistant/components/airthings_ble/sensor.py
+++ b/homeassistant/components/airthings_ble/sensor.py
@@ -114,6 +114,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
),
}
+PARALLEL_UPDATES = 0
+
@callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json
index 4b38923384a..f5639e8da8f 100644
--- a/homeassistant/components/airthings_ble/strings.json
+++ b/homeassistant/components/airthings_ble/strings.json
@@ -6,6 +6,9 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "address": "The Airthings devices discovered via Bluetooth."
}
},
"bluetooth_confirm": {
@@ -17,6 +20,7 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py
index 1a4c87a940c..b7a96ddc77e 100644
--- a/homeassistant/components/airtouch4/__init__.py
+++ b/homeassistant/components/airtouch4/__init__.py
@@ -2,17 +2,14 @@
from airtouch4pyapi import AirTouch
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .coordinator import AirtouchDataUpdateCoordinator
+from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE]
-type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
-
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry."""
@@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs()
if not info:
raise ConfigEntryNotReady
- coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
+ coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/airtouch4/coordinator.py b/homeassistant/components/airtouch4/coordinator.py
index 5a080566416..e0feb205250 100644
--- a/homeassistant/components/airtouch4/coordinator.py
+++ b/homeassistant/components/airtouch4/coordinator.py
@@ -2,26 +2,34 @@
import logging
+from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL
+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__)
+type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
+
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data."""
- def __init__(self, hass, airtouch):
+ def __init__(
+ self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
+ ) -> None:
"""Initialize global Airtouch data updater."""
self.airtouch = airtouch
super().__init__(
hass,
_LOGGER,
+ config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 1b636de0a47..6e4b0b50c4c 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==1.0.0"]
+ "requirements": ["aioairzone==1.0.1"]
}
diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py
index c00e83f2c5b..813ead8b6a8 100644
--- a/homeassistant/components/airzone/select.py
+++ b/homeassistant/components/airzone/select.py
@@ -6,17 +6,19 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Final
-from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
+from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout
from aioairzone.const import (
API_COLD_ANGLE,
API_HEAT_ANGLE,
API_MODE,
+ API_Q_ADAPT,
API_SLEEP,
AZD_COLD_ANGLE,
AZD_HEAT_ANGLE,
AZD_MASTER,
AZD_MODE,
AZD_MODES,
+ AZD_Q_ADAPT,
AZD_SLEEP,
AZD_ZONES,
)
@@ -65,6 +67,14 @@ SLEEP_DICT: Final[dict[str, int]] = {
"90m": SleepTimeout.SLEEP_90,
}
+Q_ADAPT_DICT: Final[dict[str, int]] = {
+ "standard": QAdapt.STANDARD,
+ "power": QAdapt.POWER,
+ "silence": QAdapt.SILENCE,
+ "minimum": QAdapt.MINIMUM,
+ "maximum": QAdapt.MAXIMUM,
+}
+
def main_zone_options(
zone_data: dict[str, Any],
@@ -83,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
options_fn=main_zone_options,
translation_key="modes",
),
+ AirzoneSelectDescription(
+ api_param=API_Q_ADAPT,
+ entity_category=EntityCategory.CONFIG,
+ key=AZD_Q_ADAPT,
+ options=list(Q_ADAPT_DICT),
+ options_dict=Q_ADAPT_DICT,
+ translation_key="q_adapt",
+ ),
)
diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json
index c7d9701aa83..0b783769803 100644
--- a/homeassistant/components/airzone/strings.json
+++ b/homeassistant/components/airzone/strings.json
@@ -63,6 +63,16 @@
"stop": "Stop"
}
},
+ "q_adapt": {
+ "name": "Q-Adapt",
+ "state": {
+ "standard": "Standard",
+ "power": "Power",
+ "silence": "Silence",
+ "minimum": "Minimum",
+ "maximum": "Maximum"
+ }
+ },
"sleep_times": {
"name": "Sleep",
"state": {
diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json
index 8f89ec88271..c7a2baa8c37 100644
--- a/homeassistant/components/airzone_cloud/manifest.json
+++ b/homeassistant/components/airzone_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
- "requirements": ["aioairzone-cloud==0.7.1"]
+ "requirements": ["aioairzone-cloud==0.7.2"]
}
diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py
index af50147a8ef..48bedafdd1a 100644
--- a/homeassistant/components/aladdin_connect/__init__.py
+++ b/homeassistant/components/aladdin_connect/__init__.py
@@ -2,39 +2,96 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
+from genie_partner_sdk.client import AladdinConnectClient
+
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers import (
+ aiohttp_client,
+ config_entry_oauth2_flow,
+ device_registry as dr,
+)
-DOMAIN = "aladdin_connect"
+from . import api
+from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
+from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
+
+PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
- """Set up Aladdin Connect from a config entry."""
- ir.async_create_issue(
- hass,
- DOMAIN,
- DOMAIN,
- is_fixable=False,
- severity=ir.IssueSeverity.ERROR,
- translation_key="integration_removed",
- translation_placeholders={
- "entries": "/config/integrations/integration/aladdin_connect",
- },
+async def async_setup_entry(
+ hass: HomeAssistant, entry: AladdinConnectConfigEntry
+) -> bool:
+ """Set up Aladdin Connect Genie from a config entry."""
+ implementation = (
+ await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
)
+ session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
+
+ client = AladdinConnectClient(
+ api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
+ )
+
+ doors = await client.get_doors()
+
+ entry.runtime_data = {
+ door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
+ for door in doors
+ }
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ remove_stale_devices(hass, entry)
+
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: AladdinConnectConfigEntry
+) -> bool:
"""Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
+) -> bool:
+ """Migrate old config."""
+ if config_entry.version < CONFIG_FLOW_VERSION:
+ config_entry.async_start_reauth(hass)
+ new_data = {**config_entry.data}
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data=new_data,
+ version=CONFIG_FLOW_VERSION,
+ minor_version=CONFIG_FLOW_MINOR_VERSION,
+ )
+
return True
-async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Remove a config entry."""
- if not hass.config_entries.async_loaded_entries(DOMAIN):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
- # Remove any remaining disabled or ignored entries
- for _entry in hass.config_entries.async_entries(DOMAIN):
- hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
+def remove_stale_devices(
+ hass: HomeAssistant,
+ config_entry: AladdinConnectConfigEntry,
+) -> None:
+ """Remove stale devices from device registry."""
+ device_registry = dr.async_get(hass)
+ device_entries = dr.async_entries_for_config_entry(
+ device_registry, config_entry.entry_id
+ )
+ all_device_ids = set(config_entry.runtime_data)
+
+ for device_entry in device_entries:
+ device_id: str | None = None
+ for identifier in device_entry.identifiers:
+ if identifier[0] == DOMAIN:
+ device_id = identifier[1]
+ break
+
+ if device_id and device_id not in all_device_ids:
+ device_registry.async_update_device(
+ device_entry.id, remove_config_entry_id=config_entry.entry_id
+ )
diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py
new file mode 100644
index 00000000000..ea46bf69f4a
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/api.py
@@ -0,0 +1,33 @@
+"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
+
+from typing import cast
+
+from aiohttp import ClientSession
+from genie_partner_sdk.auth import Auth
+
+from homeassistant.helpers import config_entry_oauth2_flow
+
+API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
+API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
+
+
+class AsyncConfigEntryAuth(Auth):
+ """Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
+
+ def __init__(
+ self,
+ websession: ClientSession,
+ oauth_session: config_entry_oauth2_flow.OAuth2Session,
+ ) -> None:
+ """Initialize Aladdin Connect Genie auth."""
+ super().__init__(
+ websession, API_URL, oauth_session.token["access_token"], API_KEY
+ )
+ self._oauth_session = oauth_session
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+ if not self._oauth_session.valid_token:
+ await self._oauth_session.async_ensure_token_valid()
+
+ return cast(str, self._oauth_session.token["access_token"])
diff --git a/homeassistant/components/aladdin_connect/application_credentials.py b/homeassistant/components/aladdin_connect/application_credentials.py
new file mode 100644
index 00000000000..e8e959f1fa3
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/application_credentials.py
@@ -0,0 +1,14 @@
+"""application_credentials platform the Aladdin Connect Genie integration."""
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ authorize_url=OAUTH2_AUTHORIZE,
+ token_url=OAUTH2_TOKEN,
+ )
diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py
index a508ff89c68..dab801d4712 100644
--- a/homeassistant/components/aladdin_connect/config_flow.py
+++ b/homeassistant/components/aladdin_connect/config_flow.py
@@ -1,11 +1,74 @@
-"""Config flow for Aladdin Connect integration."""
+"""Config flow for Aladdin Connect Genie."""
-from homeassistant.config_entries import ConfigFlow
+from collections.abc import Mapping
+import logging
+from typing import Any
-from . import DOMAIN
+import jwt
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.helpers import config_entry_oauth2_flow
+
+from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
-class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
- """Handle a config flow for Aladdin Connect."""
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
- VERSION = 1
+ DOMAIN = DOMAIN
+ VERSION = CONFIG_FLOW_VERSION
+ MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Check we have the cloud integration set up."""
+ if "cloud" not in self.hass.config.components:
+ return self.async_abort(
+ reason="cloud_not_enabled",
+ description_placeholders={"default_config": "default_config"},
+ )
+ return await super().async_step_user(user_input)
+
+ async def async_step_reauth(
+ self, user_input: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon API auth error or upgrade from v1 to v2."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({}),
+ )
+ return await self.async_step_user()
+
+ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
+ """Create an oauth config entry or update existing entry for reauth."""
+ # Extract the user ID from the JWT token's 'sub' field
+ token = jwt.decode(
+ data["token"]["access_token"], options={"verify_signature": False}
+ )
+ user_id = token["sub"]
+ await self.async_set_unique_id(user_id)
+
+ if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch(reason="wrong_account")
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data=data
+ )
+
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title="Aladdin Connect", data=data)
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py
new file mode 100644
index 00000000000..5312826469e
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/const.py
@@ -0,0 +1,14 @@
+"""Constants for the Aladdin Connect Genie integration."""
+
+from typing import Final
+
+from homeassistant.components.cover import CoverEntityFeature
+
+DOMAIN = "aladdin_connect"
+CONFIG_FLOW_VERSION = 2
+CONFIG_FLOW_MINOR_VERSION = 1
+
+OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
+OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
+
+SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py
new file mode 100644
index 00000000000..718aed8e445
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/coordinator.py
@@ -0,0 +1,50 @@
+"""Coordinator for Aladdin Connect integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from genie_partner_sdk.client import AladdinConnectClient
+from genie_partner_sdk.model import GarageDoor
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
+SCAN_INTERVAL = timedelta(seconds=15)
+
+
+class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
+ """Coordinator for Aladdin Connect integration."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: AladdinConnectConfigEntry,
+ client: AladdinConnectClient,
+ garage_door: GarageDoor,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ logger=_LOGGER,
+ config_entry=entry,
+ name="Aladdin Connect Coordinator",
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = client
+ self.data = garage_door
+
+ async def _async_update_data(self) -> GarageDoor:
+ """Fetch data from the Aladdin Connect API."""
+ await self.client.update_door(self.data.device_id, self.data.door_number)
+ self.data.status = self.client.get_door_status(
+ self.data.device_id, self.data.door_number
+ )
+ self.data.battery_level = self.client.get_battery_status(
+ self.data.device_id, self.data.door_number
+ )
+ return self.data
diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py
new file mode 100644
index 00000000000..4bc787539fd
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/cover.py
@@ -0,0 +1,64 @@
+"""Cover Entity for Genie Garage Door."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.cover import CoverDeviceClass, CoverEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import SUPPORTED_FEATURES
+from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
+from .entity import AladdinConnectEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AladdinConnectConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the cover platform."""
+ coordinators = entry.runtime_data
+
+ async_add_entities(
+ AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
+ )
+
+
+class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
+ """Representation of Aladdin Connect cover."""
+
+ _attr_device_class = CoverDeviceClass.GARAGE
+ _attr_supported_features = SUPPORTED_FEATURES
+ _attr_name = None
+
+ def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
+ """Initialize the Aladdin Connect cover."""
+ super().__init__(coordinator)
+ self._attr_unique_id = coordinator.data.unique_id
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Issue open command to cover."""
+ await self.client.open_door(self._device_id, self._number)
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Issue close command to cover."""
+ await self.client.close_door(self._device_id, self._number)
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Update is closed attribute."""
+ if (status := self.coordinator.data.status) is None:
+ return None
+ return status == "closed"
+
+ @property
+ def is_closing(self) -> bool | None:
+ """Update is closing attribute."""
+ return self.coordinator.data.status == "closing"
+
+ @property
+ def is_opening(self) -> bool | None:
+ """Update is opening attribute."""
+ return self.coordinator.data.status == "opening"
diff --git a/homeassistant/components/aladdin_connect/entity.py b/homeassistant/components/aladdin_connect/entity.py
new file mode 100644
index 00000000000..39a38fbd1ca
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/entity.py
@@ -0,0 +1,32 @@
+"""Base class for Aladdin Connect entities."""
+
+from genie_partner_sdk.client import AladdinConnectClient
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import AladdinConnectCoordinator
+
+
+class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
+ """Defines a base Aladdin Connect entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
+ """Initialize Aladdin Connect entity."""
+ super().__init__(coordinator)
+ device = coordinator.data
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.unique_id)},
+ manufacturer="Aladdin Connect",
+ name=device.name,
+ )
+ self._device_id = device.device_id
+ self._number = device.door_number
+
+ @property
+ def client(self) -> AladdinConnectClient:
+ """Return the client for this entity."""
+ return self.coordinator.client
diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json
index adf0d9c9b5b..e19d5c61d04 100644
--- a/homeassistant/components/aladdin_connect/manifest.json
+++ b/homeassistant/components/aladdin_connect/manifest.json
@@ -1,9 +1,16 @@
{
"domain": "aladdin_connect",
"name": "Aladdin Connect",
- "codeowners": [],
+ "codeowners": ["@swcloudgenie"],
+ "config_flow": true,
+ "dependencies": ["application_credentials"],
+ "dhcp": [
+ {
+ "hostname": "gdocntl-*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
- "integration_type": "system",
+ "integration_type": "hub",
"iot_class": "cloud_polling",
- "requirements": []
+ "requirements": ["genie-partner-sdk==1.0.11"]
}
diff --git a/homeassistant/components/aladdin_connect/quality_scale.yaml b/homeassistant/components/aladdin_connect/quality_scale.yaml
new file mode 100644
index 00000000000..88d454a5532
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/quality_scale.yaml
@@ -0,0 +1,94 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register any service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: todo
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration does not register any service actions.
+ docs-high-level-description: done
+ docs-installation-instructions:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-removal-instructions:
+ status: todo
+ comment: Documentation needs to be created.
+ entity-event-setup:
+ status: exempt
+ comment: Integration does not subscribe to external events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: todo
+ comment: Config flow does not currently test connection during setup.
+ test-before-setup: todo
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-installation-parameters:
+ status: todo
+ comment: Documentation needs to be created.
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery: todo
+ discovery-update-info: todo
+ docs-data-update:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-examples:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-known-limitations:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-supported-devices:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-supported-functions:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-troubleshooting:
+ status: todo
+ comment: Documentation needs to be created.
+ docs-use-cases:
+ status: todo
+ comment: Documentation needs to be created.
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices:
+ status: todo
+ comment: Stale devices can be done dynamically
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py
new file mode 100644
index 00000000000..d327a138244
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/sensor.py
@@ -0,0 +1,77 @@
+"""Support for Aladdin Connect Genie sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from genie_partner_sdk.model import GarageDoor
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
+from .entity import AladdinConnectEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class AladdinConnectSensorEntityDescription(SensorEntityDescription):
+ """Sensor entity description for Aladdin Connect."""
+
+ value_fn: Callable[[GarageDoor], float | None]
+
+
+SENSOR_TYPES: tuple[AladdinConnectSensorEntityDescription, ...] = (
+ AladdinConnectSensorEntityDescription(
+ key="battery_level",
+ device_class=SensorDeviceClass.BATTERY,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda garage_door: garage_door.battery_level,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AladdinConnectConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Aladdin Connect sensor devices."""
+ coordinators = entry.runtime_data
+
+ async_add_entities(
+ AladdinConnectSensor(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in SENSOR_TYPES
+ )
+
+
+class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
+ """A sensor implementation for Aladdin Connect device."""
+
+ entity_description: AladdinConnectSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: AladdinConnectCoordinator,
+ entity_description: AladdinConnectSensorEntityDescription,
+ ) -> None:
+ """Initialize the Aladdin Connect sensor."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json
index f62e68de64e..c452ba66865 100644
--- a/homeassistant/components/aladdin_connect/strings.json
+++ b/homeassistant/components/aladdin_connect/strings.json
@@ -1,8 +1,34 @@
{
- "issues": {
- "integration_removed": {
- "title": "The Aladdin Connect integration has been removed",
- "description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
+ "config": {
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "Aladdin Connect needs to re-authenticate your account"
+ },
+ "oauth_discovery": {
+ "description": "Home Assistant has found an Aladdin Connect device on your network. Press **Submit** to continue setting up Aladdin Connect."
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.",
+ "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}
diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py
index fde4638e179..55adcdf3da2 100644
--- a/homeassistant/components/alarm_control_panel/__init__.py
+++ b/homeassistant/components/alarm_control_panel/__init__.py
@@ -61,7 +61,7 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Track states and offer events for sensors."""
+ """Set up the alarm control panel component."""
component = hass.data[DATA_COMPONENT] = EntityComponent[AlarmControlPanelEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py
index b6ce87941f6..8be19850881 100644
--- a/homeassistant/components/alert/__init__.py
+++ b/homeassistant/components/alert/__init__.py
@@ -1,4 +1,7 @@
-"""Support for repeating alerts when conditions are met."""
+"""Support for repeating alerts when conditions are met.
+
+DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
+"""
from __future__ import annotations
@@ -63,7 +66,10 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Alert component."""
+ """Set up the Alert component.
+
+ DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
+ """
component = EntityComponent[AlertEntity](LOGGER, DOMAIN, hass)
entities: list[AlertEntity] = []
diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py
index a11b281428f..f4497e0f7ad 100644
--- a/homeassistant/components/alert/entity.py
+++ b/homeassistant/components/alert/entity.py
@@ -1,4 +1,7 @@
-"""Support for repeating alerts when conditions are met."""
+"""Support for repeating alerts when conditions are met.
+
+DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
+"""
from __future__ import annotations
@@ -27,7 +30,10 @@ from .const import DOMAIN, LOGGER
class AlertEntity(Entity):
- """Representation of an alert."""
+ """Representation of an alert.
+
+ DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
+ """
_attr_should_poll = False
diff --git a/homeassistant/components/alert/reproduce_state.py b/homeassistant/components/alert/reproduce_state.py
index db540369d84..dee20bc1c5d 100644
--- a/homeassistant/components/alert/reproduce_state.py
+++ b/homeassistant/components/alert/reproduce_state.py
@@ -1,4 +1,7 @@
-"""Reproduce an Alert state."""
+"""Reproduce an Alert state.
+
+DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
+"""
from __future__ import annotations
diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py
index c08e2f1c010..af0a3d7818c 100644
--- a/homeassistant/components/alexa_devices/__init__.py
+++ b/homeassistant/components/alexa_devices/__init__.py
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
-from .const import _LOGGER, COUNTRY_DOMAINS, DOMAIN
+from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services
@@ -42,26 +42,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
- if entry.version == 1 and entry.minor_version == 0:
+
+ if entry.version == 1 and entry.minor_version < 3:
+ if CONF_SITE in entry.data:
+ # Site in data (wrong place), just move to login data
+ new_data = entry.data.copy()
+ new_data[CONF_LOGIN_DATA][CONF_SITE] = new_data[CONF_SITE]
+ new_data.pop(CONF_SITE)
+ hass.config_entries.async_update_entry(
+ entry, data=new_data, version=1, minor_version=3
+ )
+ return True
+
+ if CONF_SITE in entry.data[CONF_LOGIN_DATA]:
+ # Site is there, just update version to avoid future migrations
+ hass.config_entries.async_update_entry(entry, version=1, minor_version=3)
+ return True
+
_LOGGER.debug(
"Migrating from version %s.%s", entry.version, entry.minor_version
)
# Convert country in domain
- country = entry.data[CONF_COUNTRY]
+ country = entry.data[CONF_COUNTRY].lower()
domain = COUNTRY_DOMAINS.get(country, country)
- # Save domain and remove country
+ # Add site to login data
new_data = entry.data.copy()
- new_data.update({"site": f"https://www.amazon.{domain}"})
+ new_data[CONF_LOGIN_DATA][CONF_SITE] = f"https://www.amazon.{domain}"
hass.config_entries.async_update_entry(
- entry, data=new_data, version=1, minor_version=1
+ entry, data=new_data, version=1, minor_version=3
)
- _LOGGER.info(
- "Migration to version %s.%s successful", entry.version, entry.minor_version
- )
+ _LOGGER.info(
+ "Migration to version %s.%s successful", entry.version, entry.minor_version
+ )
return True
diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py
index 231f144dd89..8347fa34423 100644
--- a/homeassistant/components/alexa_devices/binary_sensor.py
+++ b/homeassistant/components/alexa_devices/binary_sensor.py
@@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
@@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
+from .utils import async_update_unique_id
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -31,6 +33,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
+ is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = (
@@ -41,46 +44,17 @@ BINARY_SENSORS: Final = (
is_on_fn=lambda device, _: device.online,
),
AmazonBinarySensorEntityDescription(
- key="bluetooth",
- entity_category=EntityCategory.DIAGNOSTIC,
- translation_key="bluetooth",
- is_on_fn=lambda device, _: device.bluetooth_state,
- ),
- AmazonBinarySensorEntityDescription(
- key="babyCryDetectionState",
- translation_key="baby_cry_detection",
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
- is_supported=lambda device, key: device.sensors.get(key) is not None,
- ),
- AmazonBinarySensorEntityDescription(
- key="beepingApplianceDetectionState",
- translation_key="beeping_appliance_detection",
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
- is_supported=lambda device, key: device.sensors.get(key) is not None,
- ),
- AmazonBinarySensorEntityDescription(
- key="coughDetectionState",
- translation_key="cough_detection",
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
- is_supported=lambda device, key: device.sensors.get(key) is not None,
- ),
- AmazonBinarySensorEntityDescription(
- key="dogBarkDetectionState",
- translation_key="dog_bark_detection",
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
- is_supported=lambda device, key: device.sensors.get(key) is not None,
- ),
- AmazonBinarySensorEntityDescription(
- key="humanPresenceDetectionState",
+ key="detectionState",
device_class=BinarySensorDeviceClass.MOTION,
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
- is_supported=lambda device, key: device.sensors.get(key) is not None,
- ),
- AmazonBinarySensorEntityDescription(
- key="waterSoundsDetectionState",
- translation_key="water_sounds_detection",
- is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
+ is_on_fn=lambda device, key: bool(
+ device.sensors[key].value != SENSOR_STATE_OFF
+ ),
is_supported=lambda device, key: device.sensors.get(key) is not None,
+ is_available_fn=lambda device, key: (
+ device.online
+ and (sensor := device.sensors.get(key)) is not None
+ and sensor.error is False
+ ),
),
)
@@ -94,13 +68,34 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
- for sensor_desc in BINARY_SENSORS
- for serial_num in coordinator.data
- if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
+ # Replace unique id for "detectionState" binary sensor
+ await async_update_unique_id(
+ hass,
+ coordinator,
+ BINARY_SENSOR_DOMAIN,
+ "humanPresenceDetectionState",
+ "detectionState",
)
+ known_devices: set[str] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data)
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
+ for sensor_desc in BINARY_SENSORS
+ for serial_num in new_devices
+ if sensor_desc.is_supported(
+ coordinator.data[serial_num], sensor_desc.key
+ )
+ )
+
+ _check_device()
+ entry.async_on_unload(coordinator.async_add_listener(_check_device))
+
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
"""Binary sensor device."""
@@ -113,3 +108,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
return self.entity_description.is_on_fn(
self.device, self.entity_description.key
)
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return (
+ self.entity_description.is_available_fn(
+ self.device, self.entity_description.key
+ )
+ and super().available
+ )
diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py
index d75ba39323d..e863f137f70 100644
--- a/homeassistant/components/alexa_devices/config_flow.py
+++ b/homeassistant/components/alexa_devices/config_flow.py
@@ -52,7 +52,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices."""
VERSION = 1
- MINOR_VERSION = 1
+ MINOR_VERSION = 3
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
data = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
- except (CannotAuthenticate, TypeError):
+ except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
@@ -107,10 +107,12 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
- await validate_input(self.hass, {**reauth_entry.data, **user_input})
+ data = await validate_input(
+ self.hass, {**reauth_entry.data, **user_input}
+ )
except CannotConnect:
errors["base"] = "cannot_connect"
- except (CannotAuthenticate, TypeError):
+ except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
@@ -119,8 +121,9 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry,
data={
CONF_USERNAME: entry_data[CONF_USERNAME],
- CONF_PASSWORD: entry_data[CONF_PASSWORD],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_CODE: user_input[CONF_CODE],
+ CONF_LOGIN_DATA: data,
},
)
diff --git a/homeassistant/components/alexa_devices/const.py b/homeassistant/components/alexa_devices/const.py
index 3ade3ad3ecd..e783f67f503 100644
--- a/homeassistant/components/alexa_devices/const.py
+++ b/homeassistant/components/alexa_devices/const.py
@@ -6,22 +6,23 @@ _LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
CONF_LOGIN_DATA = "login_data"
+CONF_SITE = "site"
-DEFAULT_DOMAIN = {"domain": "com"}
+DEFAULT_DOMAIN = "com"
COUNTRY_DOMAINS = {
"ar": DEFAULT_DOMAIN,
"at": DEFAULT_DOMAIN,
- "au": {"domain": "com.au"},
- "be": {"domain": "com.be"},
+ "au": "com.au",
+ "be": "com.be",
"br": DEFAULT_DOMAIN,
- "gb": {"domain": "co.uk"},
+ "gb": "co.uk",
"il": DEFAULT_DOMAIN,
- "jp": {"domain": "co.jp"},
- "mx": {"domain": "com.mx"},
+ "jp": "co.jp",
+ "mx": "com.mx",
"no": DEFAULT_DOMAIN,
- "nz": {"domain": "com.au"},
+ "nz": "com.au",
"pl": DEFAULT_DOMAIN,
- "tr": {"domain": "com.tr"},
+ "tr": "com.tr",
"us": DEFAULT_DOMAIN,
- "za": {"domain": "co.za"},
+ "za": "co.za",
}
diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py
index 7807c6f0efd..6ce21aa2216 100644
--- a/homeassistant/components/alexa_devices/coordinator.py
+++ b/homeassistant/components/alexa_devices/coordinator.py
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -48,12 +49,13 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
+ self.previous_devices: set[str] = set()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
await self.api.login_mode_stored_data()
- return await self.api.get_devices_data()
+ data = await self.api.get_devices_data()
except CannotConnect as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -66,9 +68,37 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
- except (CannotAuthenticate, TypeError) as err:
+ except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
+ else:
+ current_devices = set(data.keys())
+ if stale_devices := self.previous_devices - current_devices:
+ await self._async_remove_device_stale(stale_devices)
+
+ self.previous_devices = current_devices
+ return data
+
+ async def _async_remove_device_stale(
+ self,
+ stale_devices: set[str],
+ ) -> None:
+ """Remove stale device."""
+ device_registry = dr.async_get(self.hass)
+
+ for serial_num in stale_devices:
+ _LOGGER.debug(
+ "Detected change in devices: serial %s removed",
+ serial_num,
+ )
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, serial_num)}
+ )
+ if device:
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py
index 0c4cb794416..938a20fb218 100644
--- a/homeassistant/components/alexa_devices/diagnostics.py
+++ b/homeassistant/components/alexa_devices/diagnostics.py
@@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"online": device.online,
"serial number": device.serial_number,
"software version": device.software_version,
- "do not disturb": device.do_not_disturb,
- "response style": device.response_style,
- "bluetooth state": device.bluetooth_state,
+ "sensors": device.sensors,
}
diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json
index bedd4af1734..f9e8de057d0 100644
--- a/homeassistant/components/alexa_devices/icons.json
+++ b/homeassistant/components/alexa_devices/icons.json
@@ -1,44 +1,4 @@
{
- "entity": {
- "binary_sensor": {
- "bluetooth": {
- "default": "mdi:bluetooth-off",
- "state": {
- "on": "mdi:bluetooth"
- }
- },
- "baby_cry_detection": {
- "default": "mdi:account-voice-off",
- "state": {
- "on": "mdi:account-voice"
- }
- },
- "beeping_appliance_detection": {
- "default": "mdi:bell-off",
- "state": {
- "on": "mdi:bell-ring"
- }
- },
- "cough_detection": {
- "default": "mdi:blur-off",
- "state": {
- "on": "mdi:blur"
- }
- },
- "dog_bark_detection": {
- "default": "mdi:dog-side-off",
- "state": {
- "on": "mdi:dog-side"
- }
- },
- "water_sounds_detection": {
- "default": "mdi:water-pump-off",
- "state": {
- "on": "mdi:water-pump"
- }
- }
- }
- },
"services": {
"send_sound": {
"service": "mdi:cast-audio"
diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json
index cba3af83f44..e5badd35f17 100644
--- a/homeassistant/components/alexa_devices/manifest.json
+++ b/homeassistant/components/alexa_devices/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
- "quality_scale": "silver",
- "requirements": ["aioamazondevices==5.0.0"]
+ "quality_scale": "platinum",
+ "requirements": ["aioamazondevices==6.2.9"]
}
diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py
index 08f2e214f38..d046b580cb7 100644
--- a/homeassistant/components/alexa_devices/notify.py
+++ b/homeassistant/components/alexa_devices/notify.py
@@ -57,13 +57,23 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
- for sensor_desc in NOTIFY
- for serial_num in coordinator.data
- if sensor_desc.subkey in coordinator.data[serial_num].capabilities
- and sensor_desc.is_supported(coordinator.data[serial_num])
- )
+ known_devices: set[str] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data)
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
+ for sensor_desc in NOTIFY
+ for serial_num in new_devices
+ if sensor_desc.subkey in coordinator.data[serial_num].capabilities
+ and sensor_desc.is_supported(coordinator.data[serial_num])
+ )
+
+ _check_device()
+ entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml
index e2583b29e94..0933f178359 100644
--- a/homeassistant/components/alexa_devices/quality_scale.yaml
+++ b/homeassistant/components/alexa_devices/quality_scale.yaml
@@ -53,7 +53,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
- dynamic-devices: todo
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -64,9 +64,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
- stale-devices:
- status: todo
- comment: automate the cleanup process
+ stale-devices: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py
index 89c2bdce9b7..57332b8ce3b 100644
--- a/homeassistant/components/alexa_devices/sensor.py
+++ b/homeassistant/components/alexa_devices/sensor.py
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorStateClass,
)
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
from homeassistant.core import HomeAssistant
@@ -30,22 +31,29 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
"""Amazon Devices sensor entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
+ is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
+ device.online
+ and (sensor := device.sensors.get(key)) is not None
+ and sensor.error is False
+ )
SENSORS: Final = (
AmazonSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
- native_unit_of_measurement_fn=lambda device, _key: (
+ native_unit_of_measurement_fn=lambda device, key: (
UnitOfTemperature.CELSIUS
- if device.sensors[_key].scale == "CELSIUS"
+ if key in device.sensors and device.sensors[key].scale == "CELSIUS"
else UnitOfTemperature.FAHRENHEIT
),
+ state_class=SensorStateClass.MEASUREMENT,
),
AmazonSensorEntityDescription(
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
+ state_class=SensorStateClass.MEASUREMENT,
),
)
@@ -59,12 +67,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- AmazonSensorEntity(coordinator, serial_num, sensor_desc)
- for sensor_desc in SENSORS
- for serial_num in coordinator.data
- if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
- )
+ known_devices: set[str] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data)
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ AmazonSensorEntity(coordinator, serial_num, sensor_desc)
+ for sensor_desc in SENSORS
+ for serial_num in new_devices
+ if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
+ )
+
+ _check_device()
+ entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonSensorEntity(AmazonEntity, SensorEntity):
@@ -86,3 +104,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.device.sensors[self.entity_description.key].value
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return (
+ self.entity_description.is_available_fn(
+ self.device, self.entity_description.key
+ )
+ and super().available
+ )
diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py
index 5463c7a4319..9d225a7beac 100644
--- a/homeassistant/components/alexa_devices/services.py
+++ b/homeassistant/components/alexa_devices/services.py
@@ -14,14 +14,12 @@ from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
-ATTR_SOUND_VARIANT = "sound_variant"
SERVICE_TEXT_COMMAND = "send_text_command"
SERVICE_SOUND_NOTIFICATION = "send_sound"
SCHEMA_SOUND_SERVICE = vol.Schema(
{
vol.Required(ATTR_SOUND): cv.string,
- vol.Required(ATTR_SOUND_VARIANT): cv.positive_int,
vol.Required(ATTR_DEVICE_ID): cv.string,
},
)
@@ -75,17 +73,14 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
coordinator = config_entry.runtime_data
if attribute == ATTR_SOUND:
- variant: int = call.data[ATTR_SOUND_VARIANT]
- pad = "_" if variant > 10 else "_0"
- file = f"{value}{pad}{variant!s}"
- if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]:
+ if value not in SOUNDS_LIST:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_sound_value",
- translation_placeholders={"sound": value, "variant": str(variant)},
+ translation_placeholders={"sound": value},
)
await coordinator.api.call_alexa_sound(
- coordinator.data[device.serial_number], file
+ coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
await coordinator.api.call_alexa_text_command(
diff --git a/homeassistant/components/alexa_devices/services.yaml b/homeassistant/components/alexa_devices/services.yaml
index d9eef28aea2..8194e75a8d6 100644
--- a/homeassistant/components/alexa_devices/services.yaml
+++ b/homeassistant/components/alexa_devices/services.yaml
@@ -18,14 +18,6 @@ send_sound:
selector:
device:
integration: alexa_devices
- sound_variant:
- required: true
- example: 1
- default: 1
- selector:
- number:
- min: 1
- max: 50
sound:
required: true
example: amzn_sfx_doorbell_chime
@@ -33,472 +25,45 @@ send_sound:
selector:
select:
options:
- - air_horn
- - air_horns
- - airboat
- - airport
- - aliens
- - amzn_sfx_airplane_takeoff_whoosh
- - amzn_sfx_army_march_clank_7x
- - amzn_sfx_army_march_large_8x
- - amzn_sfx_army_march_small_8x
- - amzn_sfx_baby_big_cry
- - amzn_sfx_baby_cry
- - amzn_sfx_baby_fuss
- - amzn_sfx_battle_group_clanks
- - amzn_sfx_battle_man_grunts
- - amzn_sfx_battle_men_grunts
- - amzn_sfx_battle_men_horses
- - amzn_sfx_battle_noisy_clanks
- - amzn_sfx_battle_yells_men
- - amzn_sfx_battle_yells_men_run
- - amzn_sfx_bear_groan_roar
- - amzn_sfx_bear_roar_grumble
- - amzn_sfx_bear_roar_small
- - amzn_sfx_beep_1x
- - amzn_sfx_bell_med_chime
- - amzn_sfx_bell_short_chime
- - amzn_sfx_bell_timer
- - amzn_sfx_bicycle_bell_ring
- - amzn_sfx_bird_chickadee_chirp_1x
- - amzn_sfx_bird_chickadee_chirps
- - amzn_sfx_bird_forest
- - amzn_sfx_bird_forest_short
- - amzn_sfx_bird_robin_chirp_1x
- - amzn_sfx_boing_long_1x
- - amzn_sfx_boing_med_1x
- - amzn_sfx_boing_short_1x
- - amzn_sfx_bus_drive_past
- - amzn_sfx_buzz_electronic
- - amzn_sfx_buzzer_loud_alarm
- - amzn_sfx_buzzer_small
- - amzn_sfx_car_accelerate
- - amzn_sfx_car_accelerate_noisy
- - amzn_sfx_car_click_seatbelt
- - amzn_sfx_car_close_door_1x
- - amzn_sfx_car_drive_past
- - amzn_sfx_car_honk_1x
- - amzn_sfx_car_honk_2x
- - amzn_sfx_car_honk_3x
- - amzn_sfx_car_honk_long_1x
- - amzn_sfx_car_into_driveway
- - amzn_sfx_car_into_driveway_fast
- - amzn_sfx_car_slam_door_1x
- - amzn_sfx_car_undo_seatbelt
- - amzn_sfx_cat_angry_meow_1x
- - amzn_sfx_cat_angry_screech_1x
- - amzn_sfx_cat_long_meow_1x
- - amzn_sfx_cat_meow_1x
- - amzn_sfx_cat_purr
- - amzn_sfx_cat_purr_meow
- - amzn_sfx_chicken_cluck
- - amzn_sfx_church_bell_1x
- - amzn_sfx_church_bells_ringing
- - amzn_sfx_clear_throat_ahem
- - amzn_sfx_clock_ticking
- - amzn_sfx_clock_ticking_long
- - amzn_sfx_copy_machine
- - amzn_sfx_cough
- - amzn_sfx_crow_caw_1x
- - amzn_sfx_crowd_applause
- - amzn_sfx_crowd_bar
- - amzn_sfx_crowd_bar_rowdy
- - amzn_sfx_crowd_boo
- - amzn_sfx_crowd_cheer_med
- - amzn_sfx_crowd_excited_cheer
- - amzn_sfx_dog_med_bark_1x
- - amzn_sfx_dog_med_bark_2x
- - amzn_sfx_dog_med_bark_growl
- - amzn_sfx_dog_med_growl_1x
- - amzn_sfx_dog_med_woof_1x
- - amzn_sfx_dog_small_bark_2x
- - amzn_sfx_door_open
- - amzn_sfx_door_shut
- - amzn_sfx_doorbell
- - amzn_sfx_doorbell_buzz
- - amzn_sfx_doorbell_chime
- - amzn_sfx_drinking_slurp
- - amzn_sfx_drum_and_cymbal
- - amzn_sfx_drum_comedy
- - amzn_sfx_earthquake_rumble
- - amzn_sfx_electric_guitar
- - amzn_sfx_electronic_beep
- - amzn_sfx_electronic_major_chord
- - amzn_sfx_elephant
- - amzn_sfx_elevator_bell_1x
- - amzn_sfx_elevator_open_bell
- - amzn_sfx_fairy_melodic_chimes
- - amzn_sfx_fairy_sparkle_chimes
- - amzn_sfx_faucet_drip
- - amzn_sfx_faucet_running
- - amzn_sfx_fireplace_crackle
- - amzn_sfx_fireworks
- - amzn_sfx_fireworks_firecrackers
- - amzn_sfx_fireworks_launch
- - amzn_sfx_fireworks_whistles
- - amzn_sfx_food_frying
- - amzn_sfx_footsteps
- - amzn_sfx_footsteps_muffled
- - amzn_sfx_ghost_spooky
- - amzn_sfx_glass_on_table
- - amzn_sfx_glasses_clink
- - amzn_sfx_horse_gallop_4x
- - amzn_sfx_horse_huff_whinny
- - amzn_sfx_horse_neigh
- - amzn_sfx_horse_neigh_low
- - amzn_sfx_horse_whinny
- - amzn_sfx_human_walking
- - amzn_sfx_jar_on_table_1x
- - amzn_sfx_kitchen_ambience
- - amzn_sfx_large_crowd_cheer
- - amzn_sfx_large_fire_crackling
- - amzn_sfx_laughter
- - amzn_sfx_laughter_giggle
- - amzn_sfx_lightning_strike
- - amzn_sfx_lion_roar
- - amzn_sfx_magic_blast_1x
- - amzn_sfx_monkey_calls_3x
- - amzn_sfx_monkey_chimp
- - amzn_sfx_monkeys_chatter
- - amzn_sfx_motorcycle_accelerate
- - amzn_sfx_motorcycle_engine_idle
- - amzn_sfx_motorcycle_engine_rev
- - amzn_sfx_musical_drone_intro
- - amzn_sfx_oars_splashing_rowboat
- - amzn_sfx_object_on_table_2x
- - amzn_sfx_ocean_wave_1x
- - amzn_sfx_ocean_wave_on_rocks_1x
- - amzn_sfx_ocean_wave_surf
- - amzn_sfx_people_walking
- - amzn_sfx_person_running
- - amzn_sfx_piano_note_1x
- - amzn_sfx_punch
- - amzn_sfx_rain
- - amzn_sfx_rain_on_roof
- - amzn_sfx_rain_thunder
- - amzn_sfx_rat_squeak_2x
- - amzn_sfx_rat_squeaks
- - amzn_sfx_raven_caw_1x
- - amzn_sfx_raven_caw_2x
- - amzn_sfx_restaurant_ambience
- - amzn_sfx_rooster_crow
- - amzn_sfx_scifi_air_escaping
- - amzn_sfx_scifi_alarm
- - amzn_sfx_scifi_alien_voice
- - amzn_sfx_scifi_boots_walking
- - amzn_sfx_scifi_close_large_explosion
- - amzn_sfx_scifi_door_open
- - amzn_sfx_scifi_engines_on
- - amzn_sfx_scifi_engines_on_large
- - amzn_sfx_scifi_engines_on_short_burst
- - amzn_sfx_scifi_explosion
- - amzn_sfx_scifi_explosion_2x
- - amzn_sfx_scifi_incoming_explosion
- - amzn_sfx_scifi_laser_gun_battle
- - amzn_sfx_scifi_laser_gun_fires
- - amzn_sfx_scifi_laser_gun_fires_large
- - amzn_sfx_scifi_long_explosion_1x
- - amzn_sfx_scifi_missile
- - amzn_sfx_scifi_motor_short_1x
- - amzn_sfx_scifi_open_airlock
- - amzn_sfx_scifi_radar_high_ping
- - amzn_sfx_scifi_radar_low
- - amzn_sfx_scifi_radar_medium
- - amzn_sfx_scifi_run_away
- - amzn_sfx_scifi_sheilds_up
- - amzn_sfx_scifi_short_low_explosion
- - amzn_sfx_scifi_small_whoosh_flyby
- - amzn_sfx_scifi_small_zoom_flyby
- - amzn_sfx_scifi_sonar_ping_3x
- - amzn_sfx_scifi_sonar_ping_4x
- - amzn_sfx_scifi_spaceship_flyby
- - amzn_sfx_scifi_timer_beep
- - amzn_sfx_scifi_zap_backwards
- - amzn_sfx_scifi_zap_electric
- - amzn_sfx_sheep_baa
- - amzn_sfx_sheep_bleat
- - amzn_sfx_silverware_clank
- - amzn_sfx_sirens
- - amzn_sfx_sleigh_bells
- - amzn_sfx_small_stream
- - amzn_sfx_sneeze
- - amzn_sfx_stream
- - amzn_sfx_strong_wind_desert
- - amzn_sfx_strong_wind_whistling
- - amzn_sfx_subway_leaving
- - amzn_sfx_subway_passing
- - amzn_sfx_subway_stopping
- - amzn_sfx_swoosh_cartoon_fast
- - amzn_sfx_swoosh_fast_1x
- - amzn_sfx_swoosh_fast_6x
- - amzn_sfx_test_tone
- - amzn_sfx_thunder_rumble
- - amzn_sfx_toilet_flush
- - amzn_sfx_trumpet_bugle
- - amzn_sfx_turkey_gobbling
- - amzn_sfx_typing_medium
- - amzn_sfx_typing_short
- - amzn_sfx_typing_typewriter
- - amzn_sfx_vacuum_off
- - amzn_sfx_vacuum_on
- - amzn_sfx_walking_in_mud
- - amzn_sfx_walking_in_snow
- - amzn_sfx_walking_on_grass
- - amzn_sfx_water_dripping
- - amzn_sfx_water_droplets
- - amzn_sfx_wind_strong_gusting
- - amzn_sfx_wind_whistling_desert
- - amzn_sfx_wings_flap_4x
- - amzn_sfx_wings_flap_fast
- - amzn_sfx_wolf_howl
- - amzn_sfx_wolf_young_howl
- - amzn_sfx_wooden_door
- - amzn_sfx_wooden_door_creaks_long
- - amzn_sfx_wooden_door_creaks_multiple
- - amzn_sfx_wooden_door_creaks_open
- - amzn_ui_sfx_gameshow_bridge
- - amzn_ui_sfx_gameshow_countdown_loop_32s_full
- - amzn_ui_sfx_gameshow_countdown_loop_64s_full
- - amzn_ui_sfx_gameshow_countdown_loop_64s_minimal
- - amzn_ui_sfx_gameshow_intro
- - amzn_ui_sfx_gameshow_negative_response
- - amzn_ui_sfx_gameshow_neutral_response
- - amzn_ui_sfx_gameshow_outro
- - amzn_ui_sfx_gameshow_player1
- - amzn_ui_sfx_gameshow_player2
- - amzn_ui_sfx_gameshow_player3
- - amzn_ui_sfx_gameshow_player4
- - amzn_ui_sfx_gameshow_positive_response
- - amzn_ui_sfx_gameshow_tally_negative
- - amzn_ui_sfx_gameshow_tally_positive
- - amzn_ui_sfx_gameshow_waiting_loop_30s
- - anchor
- - answering_machines
- - arcs_sparks
- - arrows_bows
- - baby
- - back_up_beeps
- - bars_restaurants
- - baseball
- - basketball
- - battles
- - beeps_tones
- - bell
- - bikes
- - billiards
- - board_games
- - body
- - boing
- - books
- - bow_wash
- - box
- - break_shatter_smash
- - breaks
- - brooms_mops
- - bullets
- - buses
- - buzz
- - buzz_hums
- - buzzers
- - buzzers_pistols
- - cables_metal
- - camera
- - cannons
- - car_alarm
- - car_alarms
- - car_cell_phones
- - carnivals_fairs
- - cars
- - casino
- - casinos
- - cellar
- - chimes
- - chimes_bells
- - chorus
- - christmas
- - church_bells
- - clock
- - cloth
- - concrete
- - construction
- - construction_factory
- - crashes
- - crowds
- - debris
- - dining_kitchens
- - dinosaurs
- - dripping
- - drops
- - electric
- - electrical
- - elevator
- - evolution_monsters
- - explosions
- - factory
- - falls
- - fax_scanner_copier
- - feedback_mics
- - fight
- - fire
- - fire_extinguisher
- - fireballs
- - fireworks
- - fishing_pole
- - flags
- - football
- - footsteps
- - futuristic
- - futuristic_ship
- - gameshow
- - gear
- - ghosts_demons
- - giant_monster
- - glass
- - glasses_clink
- - golf
- - gorilla
- - grenade_lanucher
- - griffen
- - gyms_locker_rooms
- - handgun_loading
- - handgun_shot
- - handle
- - hands
- - heartbeats_ekg
- - helicopter
- - high_tech
- - hit_punch_slap
- - hits
- - horns
- - horror
- - hot_tub_filling_up
- - human
- - human_vocals
- - hygene # codespell:ignore
- - ice_skating
- - ignitions
- - infantry
- - intro
- - jet
- - juggling
- - key_lock
- - kids
- - knocks
- - lab_equip
- - lacrosse
- - lamps_lanterns
- - leather
- - liquid_suction
- - locker_doors
- - machine_gun
- - magic_spells
- - medium_large_explosions
- - metal
- - modern_rings
- - money_coins
- - motorcycles
- - movement
- - moves
- - nature
- - oar_boat
- - pagers
- - paintball
- - paper
- - parachute
- - pay_phones
- - phone_beeps
- - pigmy_bats
- - pills
- - pour_water
- - power_up_down
- - printers
- - prison
- - public_space
- - racquetball
- - radios_static
- - rain
- - rc_airplane
- - rc_car
- - refrigerators_freezers
- - regular
- - respirator
- - rifle
- - roller_coaster
- - rollerskates_rollerblades
- - room_tones
- - ropes_climbing
- - rotary_rings
- - rowboat_canoe
- - rubber
- - running
- - sails
- - sand_gravel
- - screen_doors
- - screens
- - seats_stools
- - servos
- - shoes_boots
- - shotgun
- - shower
- - sink_faucet
- - sink_filling_water
- - sink_run_and_off
- - sink_water_splatter
- - sirens
- - skateboards
- - ski
- - skids_tires
- - sled
- - slides
- - small_explosions
- - snow
- - snowmobile
- - soldiers
- - splash_water
- - splashes_sprays
- - sports_whistles
- - squeaks
- - squeaky
- - stairs
- - steam
- - submarine_diesel
- - swing_doors
- - switches_levers
- - swords
- - tape
- - tape_machine
- - televisions_shows
- - tennis_pingpong
- - textile
- - throw
- - thunder
- - ticks
- - timer
- - toilet_flush
- - tone
- - tones_noises
- - toys
- - tractors
- - traffic
- - train
- - trucks_vans
- - turnstiles
- - typing
- - umbrella
- - underwater
- - vampires
- - various
- - video_tunes
- - volcano_earthquake
- - watches
- - water
- - water_running
- - werewolves
- - winches_gears
- - wind
- - wood
- - wood_boat
- - woosh
- - zap
- - zippers
+ - air_horn_03
+ - amzn_sfx_cat_meow_1x_01
+ - amzn_sfx_church_bell_1x_02
+ - amzn_sfx_crowd_applause_01
+ - amzn_sfx_dog_med_bark_1x_02
+ - amzn_sfx_doorbell_01
+ - amzn_sfx_doorbell_chime_01
+ - amzn_sfx_doorbell_chime_02
+ - amzn_sfx_large_crowd_cheer_01
+ - amzn_sfx_lion_roar_02
+ - amzn_sfx_rooster_crow_01
+ - amzn_sfx_scifi_alarm_01
+ - amzn_sfx_scifi_alarm_04
+ - amzn_sfx_scifi_engines_on_02
+ - amzn_sfx_scifi_sheilds_up_01
+ - amzn_sfx_trumpet_bugle_04
+ - amzn_sfx_wolf_howl_02
+ - bell_02
+ - boing_01
+ - boing_03
+ - buzzers_pistols_01
+ - camera_01
+ - christmas_05
+ - clock_01
+ - futuristic_10
+ - halloween_bats
+ - halloween_crows
+ - halloween_footsteps
+ - halloween_wind
+ - halloween_wolf
+ - holiday_halloween_ghost
+ - horror_10
+ - med_system_alerts_minimal_dragon_short
+ - med_system_alerts_minimal_owl_short
+ - med_system_alerts_minimals_blue_wave_small
+ - med_system_alerts_minimals_galaxy_short
+ - med_system_alerts_minimals_panda_short
+ - med_system_alerts_minimals_tiger_short
+ - med_ui_success_generic_1-1
+ - squeaky_12
+ - zap_01
translation_key: sound
diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json
index b1e9027ca53..f6b850f0920 100644
--- a/homeassistant/components/alexa_devices/strings.json
+++ b/homeassistant/components/alexa_devices/strings.json
@@ -58,26 +58,6 @@
}
},
"entity": {
- "binary_sensor": {
- "bluetooth": {
- "name": "Bluetooth"
- },
- "baby_cry_detection": {
- "name": "Baby crying"
- },
- "beeping_appliance_detection": {
- "name": "Beeping appliance"
- },
- "cough_detection": {
- "name": "Coughing"
- },
- "dog_bark_detection": {
- "name": "Dog barking"
- },
- "water_sounds_detection": {
- "name": "Water sounds"
- }
- },
"notify": {
"speak": {
"name": "Speak"
@@ -104,10 +84,6 @@
"sound": {
"name": "Alexa Skill sound file",
"description": "The sound file to play."
- },
- "sound_variant": {
- "name": "Sound variant",
- "description": "The variant of the sound to play."
}
}
},
@@ -129,474 +105,47 @@
"selector": {
"sound": {
"options": {
- "air_horn": "Air Horn",
- "air_horns": "Air Horns",
- "airboat": "Airboat",
- "airport": "Airport",
- "aliens": "Aliens",
- "amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh",
- "amzn_sfx_army_march_clank_7x": "Army March Clank 7x",
- "amzn_sfx_army_march_large_8x": "Army March Large 8x",
- "amzn_sfx_army_march_small_8x": "Army March Small 8x",
- "amzn_sfx_baby_big_cry": "Baby Big Cry",
- "amzn_sfx_baby_cry": "Baby Cry",
- "amzn_sfx_baby_fuss": "Baby Fuss",
- "amzn_sfx_battle_group_clanks": "Battle Group Clanks",
- "amzn_sfx_battle_man_grunts": "Battle Man Grunts",
- "amzn_sfx_battle_men_grunts": "Battle Men Grunts",
- "amzn_sfx_battle_men_horses": "Battle Men Horses",
- "amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks",
- "amzn_sfx_battle_yells_men": "Battle Yells Men",
- "amzn_sfx_battle_yells_men_run": "Battle Yells Men Run",
- "amzn_sfx_bear_groan_roar": "Bear Groan Roar",
- "amzn_sfx_bear_roar_grumble": "Bear Roar Grumble",
- "amzn_sfx_bear_roar_small": "Bear Roar Small",
- "amzn_sfx_beep_1x": "Beep 1x",
- "amzn_sfx_bell_med_chime": "Bell Med Chime",
- "amzn_sfx_bell_short_chime": "Bell Short Chime",
- "amzn_sfx_bell_timer": "Bell Timer",
- "amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring",
- "amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x",
- "amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps",
- "amzn_sfx_bird_forest": "Bird Forest",
- "amzn_sfx_bird_forest_short": "Bird Forest Short",
- "amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x",
- "amzn_sfx_boing_long_1x": "Boing Long 1x",
- "amzn_sfx_boing_med_1x": "Boing Med 1x",
- "amzn_sfx_boing_short_1x": "Boing Short 1x",
- "amzn_sfx_bus_drive_past": "Bus Drive Past",
- "amzn_sfx_buzz_electronic": "Buzz Electronic",
- "amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm",
- "amzn_sfx_buzzer_small": "Buzzer Small",
- "amzn_sfx_car_accelerate": "Car Accelerate",
- "amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy",
- "amzn_sfx_car_click_seatbelt": "Car Click Seatbelt",
- "amzn_sfx_car_close_door_1x": "Car Close Door 1x",
- "amzn_sfx_car_drive_past": "Car Drive Past",
- "amzn_sfx_car_honk_1x": "Car Honk 1x",
- "amzn_sfx_car_honk_2x": "Car Honk 2x",
- "amzn_sfx_car_honk_3x": "Car Honk 3x",
- "amzn_sfx_car_honk_long_1x": "Car Honk Long 1x",
- "amzn_sfx_car_into_driveway": "Car Into Driveway",
- "amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast",
- "amzn_sfx_car_slam_door_1x": "Car Slam Door 1x",
- "amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt",
- "amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x",
- "amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x",
- "amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x",
- "amzn_sfx_cat_meow_1x": "Cat Meow 1x",
- "amzn_sfx_cat_purr": "Cat Purr",
- "amzn_sfx_cat_purr_meow": "Cat Purr Meow",
- "amzn_sfx_chicken_cluck": "Chicken Cluck",
- "amzn_sfx_church_bell_1x": "Church Bell 1x",
- "amzn_sfx_church_bells_ringing": "Church Bells Ringing",
- "amzn_sfx_clear_throat_ahem": "Clear Throat Ahem",
- "amzn_sfx_clock_ticking": "Clock Ticking",
- "amzn_sfx_clock_ticking_long": "Clock Ticking Long",
- "amzn_sfx_copy_machine": "Copy Machine",
- "amzn_sfx_cough": "Cough",
- "amzn_sfx_crow_caw_1x": "Crow Caw 1x",
- "amzn_sfx_crowd_applause": "Crowd Applause",
- "amzn_sfx_crowd_bar": "Crowd Bar",
- "amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy",
- "amzn_sfx_crowd_boo": "Crowd Boo",
- "amzn_sfx_crowd_cheer_med": "Crowd Cheer Med",
- "amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer",
- "amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x",
- "amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x",
- "amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl",
- "amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x",
- "amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x",
- "amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x",
- "amzn_sfx_door_open": "Door Open",
- "amzn_sfx_door_shut": "Door Shut",
- "amzn_sfx_doorbell": "Doorbell",
- "amzn_sfx_doorbell_buzz": "Doorbell Buzz",
- "amzn_sfx_doorbell_chime": "Doorbell Chime",
- "amzn_sfx_drinking_slurp": "Drinking Slurp",
- "amzn_sfx_drum_and_cymbal": "Drum And Cymbal",
- "amzn_sfx_drum_comedy": "Drum Comedy",
- "amzn_sfx_earthquake_rumble": "Earthquake Rumble",
- "amzn_sfx_electric_guitar": "Electric Guitar",
- "amzn_sfx_electronic_beep": "Electronic Beep",
- "amzn_sfx_electronic_major_chord": "Electronic Major Chord",
- "amzn_sfx_elephant": "Elephant",
- "amzn_sfx_elevator_bell_1x": "Elevator Bell 1x",
- "amzn_sfx_elevator_open_bell": "Elevator Open Bell",
- "amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes",
- "amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes",
- "amzn_sfx_faucet_drip": "Faucet Drip",
- "amzn_sfx_faucet_running": "Faucet Running",
- "amzn_sfx_fireplace_crackle": "Fireplace Crackle",
- "amzn_sfx_fireworks": "Fireworks",
- "amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers",
- "amzn_sfx_fireworks_launch": "Fireworks Launch",
- "amzn_sfx_fireworks_whistles": "Fireworks Whistles",
- "amzn_sfx_food_frying": "Food Frying",
- "amzn_sfx_footsteps": "Footsteps",
- "amzn_sfx_footsteps_muffled": "Footsteps Muffled",
- "amzn_sfx_ghost_spooky": "Ghost Spooky",
- "amzn_sfx_glass_on_table": "Glass On Table",
- "amzn_sfx_glasses_clink": "Glasses Clink",
- "amzn_sfx_horse_gallop_4x": "Horse Gallop 4x",
- "amzn_sfx_horse_huff_whinny": "Horse Huff Whinny",
- "amzn_sfx_horse_neigh": "Horse Neigh",
- "amzn_sfx_horse_neigh_low": "Horse Neigh Low",
- "amzn_sfx_horse_whinny": "Horse Whinny",
- "amzn_sfx_human_walking": "Human Walking",
- "amzn_sfx_jar_on_table_1x": "Jar On Table 1x",
- "amzn_sfx_kitchen_ambience": "Kitchen Ambience",
- "amzn_sfx_large_crowd_cheer": "Large Crowd Cheer",
- "amzn_sfx_large_fire_crackling": "Large Fire Crackling",
- "amzn_sfx_laughter": "Laughter",
- "amzn_sfx_laughter_giggle": "Laughter Giggle",
- "amzn_sfx_lightning_strike": "Lightning Strike",
- "amzn_sfx_lion_roar": "Lion Roar",
- "amzn_sfx_magic_blast_1x": "Magic Blast 1x",
- "amzn_sfx_monkey_calls_3x": "Monkey Calls 3x",
- "amzn_sfx_monkey_chimp": "Monkey Chimp",
- "amzn_sfx_monkeys_chatter": "Monkeys Chatter",
- "amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate",
- "amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle",
- "amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev",
- "amzn_sfx_musical_drone_intro": "Musical Drone Intro",
- "amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat",
- "amzn_sfx_object_on_table_2x": "Object On Table 2x",
- "amzn_sfx_ocean_wave_1x": "Ocean Wave 1x",
- "amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x",
- "amzn_sfx_ocean_wave_surf": "Ocean Wave Surf",
- "amzn_sfx_people_walking": "People Walking",
- "amzn_sfx_person_running": "Person Running",
- "amzn_sfx_piano_note_1x": "Piano Note 1x",
- "amzn_sfx_punch": "Punch",
- "amzn_sfx_rain": "Rain",
- "amzn_sfx_rain_on_roof": "Rain On Roof",
- "amzn_sfx_rain_thunder": "Rain Thunder",
- "amzn_sfx_rat_squeak_2x": "Rat Squeak 2x",
- "amzn_sfx_rat_squeaks": "Rat Squeaks",
- "amzn_sfx_raven_caw_1x": "Raven Caw 1x",
- "amzn_sfx_raven_caw_2x": "Raven Caw 2x",
- "amzn_sfx_restaurant_ambience": "Restaurant Ambience",
- "amzn_sfx_rooster_crow": "Rooster Crow",
- "amzn_sfx_scifi_air_escaping": "Scifi Air Escaping",
- "amzn_sfx_scifi_alarm": "Scifi Alarm",
- "amzn_sfx_scifi_alien_voice": "Scifi Alien Voice",
- "amzn_sfx_scifi_boots_walking": "Scifi Boots Walking",
- "amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion",
- "amzn_sfx_scifi_door_open": "Scifi Door Open",
- "amzn_sfx_scifi_engines_on": "Scifi Engines On",
- "amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large",
- "amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst",
- "amzn_sfx_scifi_explosion": "Scifi Explosion",
- "amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x",
- "amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion",
- "amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle",
- "amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires",
- "amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large",
- "amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x",
- "amzn_sfx_scifi_missile": "Scifi Missile",
- "amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x",
- "amzn_sfx_scifi_open_airlock": "Scifi Open Airlock",
- "amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping",
- "amzn_sfx_scifi_radar_low": "Scifi Radar Low",
- "amzn_sfx_scifi_radar_medium": "Scifi Radar Medium",
- "amzn_sfx_scifi_run_away": "Scifi Run Away",
- "amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up",
- "amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion",
- "amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby",
- "amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby",
- "amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x",
- "amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x",
- "amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby",
- "amzn_sfx_scifi_timer_beep": "Scifi Timer Beep",
- "amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards",
- "amzn_sfx_scifi_zap_electric": "Scifi Zap Electric",
- "amzn_sfx_sheep_baa": "Sheep Baa",
- "amzn_sfx_sheep_bleat": "Sheep Bleat",
- "amzn_sfx_silverware_clank": "Silverware Clank",
- "amzn_sfx_sirens": "Sirens",
- "amzn_sfx_sleigh_bells": "Sleigh Bells",
- "amzn_sfx_small_stream": "Small Stream",
- "amzn_sfx_sneeze": "Sneeze",
- "amzn_sfx_stream": "Stream",
- "amzn_sfx_strong_wind_desert": "Strong Wind Desert",
- "amzn_sfx_strong_wind_whistling": "Strong Wind Whistling",
- "amzn_sfx_subway_leaving": "Subway Leaving",
- "amzn_sfx_subway_passing": "Subway Passing",
- "amzn_sfx_subway_stopping": "Subway Stopping",
- "amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast",
- "amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x",
- "amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x",
- "amzn_sfx_test_tone": "Test Tone",
- "amzn_sfx_thunder_rumble": "Thunder Rumble",
- "amzn_sfx_toilet_flush": "Toilet Flush",
- "amzn_sfx_trumpet_bugle": "Trumpet Bugle",
- "amzn_sfx_turkey_gobbling": "Turkey Gobbling",
- "amzn_sfx_typing_medium": "Typing Medium",
- "amzn_sfx_typing_short": "Typing Short",
- "amzn_sfx_typing_typewriter": "Typing Typewriter",
- "amzn_sfx_vacuum_off": "Vacuum Off",
- "amzn_sfx_vacuum_on": "Vacuum On",
- "amzn_sfx_walking_in_mud": "Walking In Mud",
- "amzn_sfx_walking_in_snow": "Walking In Snow",
- "amzn_sfx_walking_on_grass": "Walking On Grass",
- "amzn_sfx_water_dripping": "Water Dripping",
- "amzn_sfx_water_droplets": "Water Droplets",
- "amzn_sfx_wind_strong_gusting": "Wind Strong Gusting",
- "amzn_sfx_wind_whistling_desert": "Wind Whistling Desert",
- "amzn_sfx_wings_flap_4x": "Wings Flap 4x",
- "amzn_sfx_wings_flap_fast": "Wings Flap Fast",
- "amzn_sfx_wolf_howl": "Wolf Howl",
- "amzn_sfx_wolf_young_howl": "Wolf Young Howl",
- "amzn_sfx_wooden_door": "Wooden Door",
- "amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long",
- "amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple",
- "amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open",
- "amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge",
- "amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full",
- "amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full",
- "amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal",
- "amzn_ui_sfx_gameshow_intro": "Gameshow Intro",
- "amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response",
- "amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response",
- "amzn_ui_sfx_gameshow_outro": "Gameshow Outro",
- "amzn_ui_sfx_gameshow_player1": "Gameshow Player1",
- "amzn_ui_sfx_gameshow_player2": "Gameshow Player2",
- "amzn_ui_sfx_gameshow_player3": "Gameshow Player3",
- "amzn_ui_sfx_gameshow_player4": "Gameshow Player4",
- "amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response",
- "amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative",
- "amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive",
- "amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s",
- "anchor": "Anchor",
- "answering_machines": "Answering Machines",
- "arcs_sparks": "Arcs Sparks",
- "arrows_bows": "Arrows Bows",
- "baby": "Baby",
- "back_up_beeps": "Back Up Beeps",
- "bars_restaurants": "Bars Restaurants",
- "baseball": "Baseball",
- "basketball": "Basketball",
- "battles": "Battles",
- "beeps_tones": "Beeps Tones",
- "bell": "Bell",
- "bikes": "Bikes",
- "billiards": "Billiards",
- "board_games": "Board Games",
- "body": "Body",
- "boing": "Boing",
- "books": "Books",
- "bow_wash": "Bow Wash",
- "box": "Box",
- "break_shatter_smash": "Break Shatter Smash",
- "breaks": "Breaks",
- "brooms_mops": "Brooms Mops",
- "bullets": "Bullets",
- "buses": "Buses",
- "buzz": "Buzz",
- "buzz_hums": "Buzz Hums",
- "buzzers": "Buzzers",
- "buzzers_pistols": "Buzzers Pistols",
- "cables_metal": "Cables Metal",
- "camera": "Camera",
- "cannons": "Cannons",
- "car_alarm": "Car Alarm",
- "car_alarms": "Car Alarms",
- "car_cell_phones": "Car Cell Phones",
- "carnivals_fairs": "Carnivals Fairs",
- "cars": "Cars",
- "casino": "Casino",
- "casinos": "Casinos",
- "cellar": "Cellar",
- "chimes": "Chimes",
- "chimes_bells": "Chimes Bells",
- "chorus": "Chorus",
- "christmas": "Christmas",
- "church_bells": "Church Bells",
- "clock": "Clock",
- "cloth": "Cloth",
- "concrete": "Concrete",
- "construction": "Construction",
- "construction_factory": "Construction Factory",
- "crashes": "Crashes",
- "crowds": "Crowds",
- "debris": "Debris",
- "dining_kitchens": "Dining Kitchens",
- "dinosaurs": "Dinosaurs",
- "dripping": "Dripping",
- "drops": "Drops",
- "electric": "Electric",
- "electrical": "Electrical",
- "elevator": "Elevator",
- "evolution_monsters": "Evolution Monsters",
- "explosions": "Explosions",
- "factory": "Factory",
- "falls": "Falls",
- "fax_scanner_copier": "Fax Scanner Copier",
- "feedback_mics": "Feedback Mics",
- "fight": "Fight",
- "fire": "Fire",
- "fire_extinguisher": "Fire Extinguisher",
- "fireballs": "Fireballs",
- "fireworks": "Fireworks",
- "fishing_pole": "Fishing Pole",
- "flags": "Flags",
- "football": "Football",
- "footsteps": "Footsteps",
- "futuristic": "Futuristic",
- "futuristic_ship": "Futuristic Ship",
- "gameshow": "Gameshow",
- "gear": "Gear",
- "ghosts_demons": "Ghosts Demons",
- "giant_monster": "Giant Monster",
- "glass": "Glass",
- "glasses_clink": "Glasses Clink",
- "golf": "Golf",
- "gorilla": "Gorilla",
- "grenade_lanucher": "Grenade Lanucher",
- "griffen": "Griffen",
- "gyms_locker_rooms": "Gyms Locker Rooms",
- "handgun_loading": "Handgun Loading",
- "handgun_shot": "Handgun Shot",
- "handle": "Handle",
- "hands": "Hands",
- "heartbeats_ekg": "Heartbeats EKG",
- "helicopter": "Helicopter",
- "high_tech": "High Tech",
- "hit_punch_slap": "Hit Punch Slap",
- "hits": "Hits",
- "horns": "Horns",
- "horror": "Horror",
- "hot_tub_filling_up": "Hot Tub Filling Up",
- "human": "Human",
- "human_vocals": "Human Vocals",
- "hygene": "Hygene",
- "ice_skating": "Ice Skating",
- "ignitions": "Ignitions",
- "infantry": "Infantry",
- "intro": "Intro",
- "jet": "Jet",
- "juggling": "Juggling",
- "key_lock": "Key Lock",
- "kids": "Kids",
- "knocks": "Knocks",
- "lab_equip": "Lab Equip",
- "lacrosse": "Lacrosse",
- "lamps_lanterns": "Lamps Lanterns",
- "leather": "Leather",
- "liquid_suction": "Liquid Suction",
- "locker_doors": "Locker Doors",
- "machine_gun": "Machine Gun",
- "magic_spells": "Magic Spells",
- "medium_large_explosions": "Medium Large Explosions",
- "metal": "Metal",
- "modern_rings": "Modern Rings",
- "money_coins": "Money Coins",
- "motorcycles": "Motorcycles",
- "movement": "Movement",
- "moves": "Moves",
- "nature": "Nature",
- "oar_boat": "Oar Boat",
- "pagers": "Pagers",
- "paintball": "Paintball",
- "paper": "Paper",
- "parachute": "Parachute",
- "pay_phones": "Pay Phones",
- "phone_beeps": "Phone Beeps",
- "pigmy_bats": "Pigmy Bats",
- "pills": "Pills",
- "pour_water": "Pour Water",
- "power_up_down": "Power Up Down",
- "printers": "Printers",
- "prison": "Prison",
- "public_space": "Public Space",
- "racquetball": "Racquetball",
- "radios_static": "Radios Static",
- "rain": "Rain",
- "rc_airplane": "RC Airplane",
- "rc_car": "RC Car",
- "refrigerators_freezers": "Refrigerators Freezers",
- "regular": "Regular",
- "respirator": "Respirator",
- "rifle": "Rifle",
- "roller_coaster": "Roller Coaster",
- "rollerskates_rollerblades": "RollerSkates RollerBlades",
- "room_tones": "Room Tones",
- "ropes_climbing": "Ropes Climbing",
- "rotary_rings": "Rotary Rings",
- "rowboat_canoe": "Rowboat Canoe",
- "rubber": "Rubber",
- "running": "Running",
- "sails": "Sails",
- "sand_gravel": "Sand Gravel",
- "screen_doors": "Screen Doors",
- "screens": "Screens",
- "seats_stools": "Seats Stools",
- "servos": "Servos",
- "shoes_boots": "Shoes Boots",
- "shotgun": "Shotgun",
- "shower": "Shower",
- "sink_faucet": "Sink Faucet",
- "sink_filling_water": "Sink Filling Water",
- "sink_run_and_off": "Sink Run And Off",
- "sink_water_splatter": "Sink Water Splatter",
- "sirens": "Sirens",
- "skateboards": "Skateboards",
- "ski": "Ski",
- "skids_tires": "Skids Tires",
- "sled": "Sled",
- "slides": "Slides",
- "small_explosions": "Small Explosions",
- "snow": "Snow",
- "snowmobile": "Snowmobile",
- "soldiers": "Soldiers",
- "splash_water": "Splash Water",
- "splashes_sprays": "Splashes Sprays",
- "sports_whistles": "Sports Whistles",
- "squeaks": "Squeaks",
- "squeaky": "Squeaky",
- "stairs": "Stairs",
- "steam": "Steam",
- "submarine_diesel": "Submarine Diesel",
- "swing_doors": "Swing Doors",
- "switches_levers": "Switches Levers",
- "swords": "Swords",
- "tape": "Tape",
- "tape_machine": "Tape Machine",
- "televisions_shows": "Televisions Shows",
- "tennis_pingpong": "Tennis PingPong",
- "textile": "Textile",
- "throw": "Throw",
- "thunder": "Thunder",
- "ticks": "Ticks",
- "timer": "Timer",
- "toilet_flush": "Toilet Flush",
- "tone": "Tone",
- "tones_noises": "Tones Noises",
- "toys": "Toys",
- "tractors": "Tractors",
- "traffic": "Traffic",
- "train": "Train",
- "trucks_vans": "Trucks Vans",
- "turnstiles": "Turnstiles",
- "typing": "Typing",
- "umbrella": "Umbrella",
- "underwater": "Underwater",
- "vampires": "Vampires",
- "various": "Various",
- "video_tunes": "Video Tunes",
- "volcano_earthquake": "Volcano Earthquake",
- "watches": "Watches",
- "water": "Water",
- "water_running": "Water Running",
- "werewolves": "Werewolves",
- "winches_gears": "Winches Gears",
- "wind": "Wind",
- "wood": "Wood",
- "wood_boat": "Wood Boat",
- "woosh": "Woosh",
- "zap": "Zap",
- "zippers": "Zippers"
+ "air_horn_03": "Air horn",
+ "amzn_sfx_cat_meow_1x_01": "Cat meow",
+ "amzn_sfx_church_bell_1x_02": "Church bell",
+ "amzn_sfx_crowd_applause_01": "Crowd applause",
+ "amzn_sfx_dog_med_bark_1x_02": "Dog bark",
+ "amzn_sfx_doorbell_01": "Doorbell 1",
+ "amzn_sfx_doorbell_chime_01": "Doorbell 2",
+ "amzn_sfx_doorbell_chime_02": "Doorbell 3",
+ "amzn_sfx_large_crowd_cheer_01": "Crowd cheers",
+ "amzn_sfx_lion_roar_02": "Lion roar",
+ "amzn_sfx_rooster_crow_01": "Rooster",
+ "amzn_sfx_scifi_alarm_01": "Sirens",
+ "amzn_sfx_scifi_alarm_04": "Red alert",
+ "amzn_sfx_scifi_engines_on_02": "Engines on",
+ "amzn_sfx_scifi_sheilds_up_01": "Shields up",
+ "amzn_sfx_trumpet_bugle_04": "Trumpet",
+ "amzn_sfx_wolf_howl_02": "Wolf howl",
+ "bell_02": "Bells",
+ "boing_01": "Boing 1",
+ "boing_03": "Boing 2",
+ "buzzers_pistols_01": "Buzzer",
+ "camera_01": "Camera",
+ "christmas_05": "Christmas bells",
+ "clock_01": "Ticking clock",
+ "futuristic_10": "Aircraft",
+ "halloween_bats": "Halloween bats",
+ "halloween_crows": "Halloween crows",
+ "halloween_footsteps": "Halloween spooky footsteps",
+ "halloween_wind": "Halloween wind",
+ "halloween_wolf": "Halloween wolf",
+ "holiday_halloween_ghost": "Halloween ghost",
+ "horror_10": "Halloween creepy door",
+ "med_system_alerts_minimal_dragon_short": "Friendly dragon",
+ "med_system_alerts_minimal_owl_short": "Happy owl",
+ "med_system_alerts_minimals_blue_wave_small": "Underwater World Sonata",
+ "med_system_alerts_minimals_galaxy_short": "Infinite Galaxy",
+ "med_system_alerts_minimals_panda_short": "Baby panda",
+ "med_system_alerts_minimals_tiger_short": "Playful tiger",
+ "med_ui_success_generic_1-1": "Success 1",
+ "squeaky_12": "Squeaky door",
+ "zap_01": "Zap"
}
}
},
@@ -614,7 +163,7 @@
"message": "Invalid device ID specified: {device_id}"
},
"invalid_sound_value": {
- "message": "Invalid sound {sound} with variant {variant} specified"
+ "message": "Invalid sound {sound} specified"
},
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py
index e53ea40965a..003f5762079 100644
--- a/homeassistant/components/alexa_devices/switch.py
+++ b/homeassistant/components/alexa_devices/switch.py
@@ -8,13 +8,17 @@ from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice
-from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
-from .utils import alexa_api_call
+from .utils import alexa_api_call, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -24,16 +28,19 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
- subkey: str
+ is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
+ device.online
+ and (sensor := device.sensors.get(key)) is not None
+ and sensor.error is False
+ )
method: str
SWITCHES: Final = (
AmazonSwitchEntityDescription(
- key="do_not_disturb",
- subkey="AUDIO_PLAYER",
+ key="dnd",
translation_key="do_not_disturb",
- is_on_fn=lambda _device: _device.do_not_disturb,
+ is_on_fn=lambda device: bool(device.sensors["dnd"].value),
method="set_do_not_disturb",
),
)
@@ -48,13 +55,28 @@ async def async_setup_entry(
coordinator = entry.runtime_data
- async_add_entities(
- AmazonSwitchEntity(coordinator, serial_num, switch_desc)
- for switch_desc in SWITCHES
- for serial_num in coordinator.data
- if switch_desc.subkey in coordinator.data[serial_num].capabilities
+ # Replace unique id for "DND" switch and remove from Speaker Group
+ await async_update_unique_id(
+ hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
+ known_devices: set[str] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data)
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ AmazonSwitchEntity(coordinator, serial_num, switch_desc)
+ for switch_desc in SWITCHES
+ for serial_num in new_devices
+ if switch_desc.key in coordinator.data[serial_num].sensors
+ )
+
+ _check_device()
+ entry.async_on_unload(coordinator.async_add_listener(_check_device))
+
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
"""Switch device."""
@@ -84,3 +106,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
def is_on(self) -> bool:
"""Return True if switch is on."""
return self.entity_description.is_on_fn(self.device)
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return (
+ self.entity_description.is_available_fn(
+ self.device, self.entity_description.key
+ )
+ and super().available
+ )
diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py
index 437b681413b..f8898aa5fe4 100644
--- a/homeassistant/components/alexa_devices/utils.py
+++ b/homeassistant/components/alexa_devices/utils.py
@@ -6,9 +6,12 @@ from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.entity_registry as er
-from .const import DOMAIN
+from .const import _LOGGER, DOMAIN
+from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
@@ -38,3 +41,23 @@ def alexa_api_call[_T: AmazonEntity, **_P](
) from err
return cmd_wrapper
+
+
+async def async_update_unique_id(
+ hass: HomeAssistant,
+ coordinator: AmazonDevicesCoordinator,
+ domain: str,
+ old_key: str,
+ new_key: str,
+) -> None:
+ """Update unique id for entities created with old format."""
+ entity_registry = er.async_get(hass)
+
+ for serial_num in coordinator.data:
+ unique_id = f"{serial_num}-{old_key}"
+ if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
+ _LOGGER.debug("Updating unique_id for %s", entity_id)
+ new_unique_id = unique_id.replace(old_key, new_key)
+
+ # Update the registry with the new unique_id
+ entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py
index 084761c4978..6b4ca8ade53 100644
--- a/homeassistant/components/amcrest/services.py
+++ b/homeassistant/components/amcrest/services.py
@@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
- call_ids = await async_extract_entity_ids(hass, call)
+ call_ids = await async_extract_entity_ids(call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py
index 83610f0dc75..230d172ca91 100644
--- a/homeassistant/components/analytics/__init__.py
+++ b/homeassistant/components/analytics/__init__.py
@@ -12,10 +12,25 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
-from .analytics import Analytics
+from .analytics import (
+ Analytics,
+ AnalyticsInput,
+ AnalyticsModifications,
+ DeviceAnalyticsModifications,
+ EntityAnalyticsModifications,
+ async_devices_payload,
+)
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView
+__all__ = [
+ "AnalyticsInput",
+ "AnalyticsModifications",
+ "DeviceAnalyticsModifications",
+ "EntityAnalyticsModifications",
+ "async_devices_payload",
+]
+
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py
index b1641e8dd48..e788fdf9714 100644
--- a/homeassistant/components/analytics/analytics.py
+++ b/homeassistant/components/analytics/analytics.py
@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
from asyncio import timeout
-from dataclasses import asdict as dataclass_asdict, dataclass
+from collections.abc import Awaitable, Callable, Iterable, Mapping
+from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime
-from typing import Any
+from typing import Any, Protocol
import uuid
import aiohttp
@@ -24,17 +25,25 @@ from homeassistant.components.recorder import (
get_instance as get_recorder_instance,
)
from homeassistant.config_entries import SOURCE_IGNORE
-from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE,
+ ATTR_DOMAIN,
+ BASE_PLATFORMS,
+ __version__ as HA_VERSION,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
+from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info
+from homeassistant.helpers.typing import UNDEFINED
from homeassistant.loader import (
Integration,
IntegrationNotFound,
+ async_get_integration,
async_get_integrations,
)
from homeassistant.setup import async_get_loaded_integrations
@@ -70,12 +79,115 @@ from .const import (
ATTR_USER_COUNT,
ATTR_UUID,
ATTR_VERSION,
+ DOMAIN,
LOGGER,
PREFERENCE_SCHEMA,
STORAGE_KEY,
STORAGE_VERSION,
)
+DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
+
+type AnalyticsModifier = Callable[
+ [HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
+]
+
+
+@singleton(DATA_ANALYTICS_MODIFIERS)
+def _async_get_modifiers(
+ hass: HomeAssistant,
+) -> dict[str, AnalyticsModifier | None]:
+ """Return the analytics modifiers."""
+ return {}
+
+
+@dataclass
+class AnalyticsInput:
+ """Analytics input for a single integration.
+
+ This is sent to integrations that implement the platform.
+ """
+
+ device_ids: Iterable[str] = field(default_factory=list)
+ entity_ids: Iterable[str] = field(default_factory=list)
+
+
+@dataclass
+class AnalyticsModifications:
+ """Analytics config for a single integration.
+
+ This is used by integrations that implement the platform.
+ """
+
+ remove: bool = False
+ devices: Mapping[str, DeviceAnalyticsModifications] | None = None
+ entities: Mapping[str, EntityAnalyticsModifications] | None = None
+
+
+@dataclass
+class DeviceAnalyticsModifications:
+ """Analytics config for a single device.
+
+ This is used by integrations that implement the platform.
+ """
+
+ remove: bool = False
+
+
+@dataclass
+class EntityAnalyticsModifications:
+ """Analytics config for a single entity.
+
+ This is used by integrations that implement the platform.
+ """
+
+ remove: bool = False
+
+
+class AnalyticsPlatformProtocol(Protocol):
+ """Define the format of analytics platforms."""
+
+ async def async_modify_analytics(
+ self,
+ hass: HomeAssistant,
+ analytics_input: AnalyticsInput,
+ ) -> AnalyticsModifications:
+ """Modify the analytics."""
+
+
+async def _async_get_analytics_platform(
+ hass: HomeAssistant, domain: str
+) -> AnalyticsPlatformProtocol | None:
+ """Get analytics platform."""
+ try:
+ integration = await async_get_integration(hass, domain)
+ except IntegrationNotFound:
+ return None
+ try:
+ return await integration.async_get_platform(DOMAIN)
+ except ImportError:
+ return None
+
+
+async def _async_get_modifier(
+ hass: HomeAssistant, domain: str
+) -> AnalyticsModifier | None:
+ """Get analytics modifier."""
+ modifiers = _async_get_modifiers(hass)
+ modifier = modifiers.get(domain, UNDEFINED)
+
+ if modifier is not UNDEFINED:
+ return modifier
+
+ platform = await _async_get_analytics_platform(hass, domain)
+ if platform is None:
+ modifiers[domain] = None
+ return None
+
+ modifier = getattr(platform, "async_modify_analytics", None)
+ modifiers[domain] = modifier
+ return modifier
+
def gen_uuid() -> str:
"""Generate a new UUID."""
@@ -388,67 +500,219 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
return domains
-async def async_devices_payload(hass: HomeAssistant) -> dict:
- """Return the devices payload."""
- devices: list[dict[str, Any]] = []
+DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
+DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
+DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
+
+
+async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
+ """Return detailed information about entities and devices."""
dev_reg = dr.async_get(hass)
- # Devices that need via device info set
- new_indexes: dict[str, int] = {}
- via_devices: dict[str, str] = {}
+ ent_reg = er.async_get(hass)
- seen_integrations = set()
+ integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
+ integration_configs: dict[str, AnalyticsModifications] = {}
- for device in dev_reg.devices.values():
- if not device.primary_config_entry:
+ removed_devices: set[str] = set()
+
+ # Get device list
+ for device_entry in dev_reg.devices.values():
+ if not device_entry.primary_config_entry:
continue
- config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
+ config_entry = hass.config_entries.async_get_entry(
+ device_entry.primary_config_entry
+ )
if config_entry is None:
continue
- seen_integrations.add(config_entry.domain)
-
- new_indexes[device.id] = len(devices)
- devices.append(
- {
- "integration": config_entry.domain,
- "manufacturer": device.manufacturer,
- "model_id": device.model_id,
- "model": device.model,
- "sw_version": device.sw_version,
- "hw_version": device.hw_version,
- "has_configuration_url": device.configuration_url is not None,
- "via_device": None,
- "entry_type": device.entry_type.value if device.entry_type else None,
- }
- )
-
- if device.via_device_id:
- via_devices[device.id] = device.via_device_id
-
- for from_device, via_device in via_devices.items():
- if via_device not in new_indexes:
+ if device_entry.entry_type is dr.DeviceEntryType.SERVICE:
+ removed_devices.add(device_entry.id)
continue
- devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
+
+ integration_domain = config_entry.domain
+
+ integration_input = integration_inputs.setdefault(integration_domain, ([], []))
+ integration_input[0].append(device_entry.id)
+
+ # Get entity list
+ for entity_entry in ent_reg.entities.values():
+ integration_domain = entity_entry.platform
+
+ integration_input = integration_inputs.setdefault(integration_domain, ([], []))
+ integration_input[1].append(entity_entry.entity_id)
integrations = {
domain: integration
for domain, integration in (
- await async_get_integrations(hass, seen_integrations)
+ await async_get_integrations(hass, integration_inputs.keys())
).items()
if isinstance(integration, Integration)
}
- for device_info in devices:
- if integration := integrations.get(device_info["integration"]):
- device_info["is_custom_integration"] = not integration.is_built_in
- # Include version for custom integrations
- if not integration.is_built_in and integration.version:
- device_info["custom_integration_version"] = str(integration.version)
+ # Filter out custom integrations and integrations that are not device or hub type
+ integration_inputs = {
+ domain: integration_info
+ for domain, integration_info in integration_inputs.items()
+ if (integration := integrations.get(domain)) is not None
+ and integration.is_built_in
+ and integration.manifest.get("integration_type") in ("device", "hub")
+ }
+
+ # Call integrations that implement the analytics platform
+ for integration_domain, integration_input in integration_inputs.items():
+ if (
+ modifier := await _async_get_modifier(hass, integration_domain)
+ ) is not None:
+ try:
+ integration_config = await modifier(
+ hass, AnalyticsInput(*integration_input)
+ )
+ except Exception as err: # noqa: BLE001
+ LOGGER.exception(
+ "Calling async_modify_analytics for integration '%s' failed: %s",
+ integration_domain,
+ err,
+ )
+ integration_configs[integration_domain] = AnalyticsModifications(
+ remove=True
+ )
+ continue
+
+ if not isinstance(integration_config, AnalyticsModifications):
+ LOGGER.error( # type: ignore[unreachable]
+ "Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
+ integration_domain,
+ )
+ integration_configs[integration_domain] = AnalyticsModifications(
+ remove=True
+ )
+ continue
+
+ integration_configs[integration_domain] = integration_config
+
+ integrations_info: dict[str, dict[str, Any]] = {}
+
+ # We need to refer to other devices, for example in `via_device` field.
+ # We don't however send the original device ids outside of Home Assistant,
+ # instead we refer to devices by (integration_domain, index_in_integration_device_list).
+ device_id_mapping: dict[str, tuple[str, int]] = {}
+
+ # Fill out information about devices
+ for integration_domain, integration_input in integration_inputs.items():
+ integration_config = integration_configs.get(
+ integration_domain, DEFAULT_ANALYTICS_CONFIG
+ )
+
+ if integration_config.remove:
+ continue
+
+ integration_info = integrations_info.setdefault(
+ integration_domain, {"devices": [], "entities": []}
+ )
+
+ devices_info = integration_info["devices"]
+
+ for device_id in integration_input[0]:
+ device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
+ if integration_config.devices is not None:
+ device_config = integration_config.devices.get(device_id, device_config)
+
+ if device_config.remove:
+ removed_devices.add(device_id)
+ continue
+
+ device_entry = dev_reg.devices[device_id]
+
+ device_id_mapping[device_id] = (integration_domain, len(devices_info))
+
+ devices_info.append(
+ {
+ "entry_type": device_entry.entry_type,
+ "has_configuration_url": device_entry.configuration_url is not None,
+ "hw_version": device_entry.hw_version,
+ "manufacturer": device_entry.manufacturer,
+ "model": device_entry.model,
+ "model_id": device_entry.model_id,
+ "sw_version": device_entry.sw_version,
+ "via_device": device_entry.via_device_id,
+ "entities": [],
+ }
+ )
+
+ # Fill out via_device with new device ids
+ for integration_info in integrations_info.values():
+ for device_info in integration_info["devices"]:
+ if device_info["via_device"] is None:
+ continue
+ device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
+
+ # Fill out information about entities
+ for integration_domain, integration_input in integration_inputs.items():
+ integration_config = integration_configs.get(
+ integration_domain, DEFAULT_ANALYTICS_CONFIG
+ )
+
+ if integration_config.remove:
+ continue
+
+ integration_info = integrations_info.setdefault(
+ integration_domain, {"devices": [], "entities": []}
+ )
+
+ devices_info = integration_info["devices"]
+ entities_info = integration_info["entities"]
+
+ for entity_id in integration_input[1]:
+ entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
+ if integration_config.entities is not None:
+ entity_config = integration_config.entities.get(
+ entity_id, entity_config
+ )
+
+ if entity_config.remove:
+ continue
+
+ entity_entry = ent_reg.entities[entity_id]
+
+ entity_state = hass.states.get(entity_id)
+
+ entity_info = {
+ # LIMITATION: `assumed_state` can be overridden by users;
+ # we should replace it with the original value in the future.
+ # It is also not present, if entity is not in the state machine,
+ # which can happen for disabled entities.
+ "assumed_state": (
+ entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
+ if entity_state is not None
+ else None
+ ),
+ "domain": entity_entry.domain,
+ "entity_category": entity_entry.entity_category,
+ "has_entity_name": entity_entry.has_entity_name,
+ "original_device_class": entity_entry.original_device_class,
+ # LIMITATION: `unit_of_measurement` can be overridden by users;
+ # we should replace it with the original value in the future.
+ "unit_of_measurement": entity_entry.unit_of_measurement,
+ }
+
+ if (device_id_ := entity_entry.device_id) is not None:
+ if device_id_ in removed_devices:
+ # The device was removed, so we remove the entity too
+ continue
+
+ if (
+ new_device_id := device_id_mapping.get(device_id_)
+ ) is not None and (new_device_id[0] == integration_domain):
+ device_info = devices_info[new_device_id[1]]
+ device_info["entities"].append(entity_info)
+ continue
+
+ entities_info.append(entity_info)
return {
"version": "home-assistant:1",
"home_assistant": HA_VERSION,
- "devices": devices,
+ "integrations": integrations_info,
}
diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json
index ab51ed31c9e..606b7a2f328 100644
--- a/homeassistant/components/analytics/manifest.json
+++ b/homeassistant/components/analytics/manifest.json
@@ -2,7 +2,7 @@
"domain": "analytics",
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
- "codeowners": ["@home-assistant/core", "@ludeeus"],
+ "codeowners": ["@home-assistant/core"],
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py
index 4ffa0e24777..a5637053e4a 100644
--- a/homeassistant/components/androidtv/__init__.py
+++ b/homeassistant/components/androidtv/__init__.py
@@ -33,9 +33,11 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import STORAGE_DIR
+from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_ADB_SERVER_IP,
@@ -46,10 +48,12 @@ from .const import (
DEFAULT_ADB_SERVER_PORT,
DEVICE_ANDROIDTV,
DEVICE_FIRETV,
+ DOMAIN,
PROP_ETHMAC,
PROP_WIFIMAC,
SIGNAL_CONFIG_ENTITY,
)
+from .services import async_setup_services
ADB_PYTHON_EXCEPTIONS: tuple = (
AdbTimeoutError,
@@ -63,6 +67,8 @@ ADB_PYTHON_EXCEPTIONS: tuple = (
)
ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
@@ -188,6 +194,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Android TV / Fire TV integration."""
+ async_setup_services(hass)
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool:
"""Set up Android Debug Bridge platform."""
diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py
index 6a60d84e39e..9621282208e 100644
--- a/homeassistant/components/androidtv/media_player.py
+++ b/homeassistant/components/androidtv/media_player.py
@@ -8,7 +8,6 @@ import logging
from androidtv.constants import APPS, KEYS
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
-import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.components.media_player import (
@@ -17,9 +16,7 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
-from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
@@ -39,19 +36,10 @@ from .const import (
SIGNAL_CONFIG_ENTITY,
)
from .entity import AndroidTVEntity, adb_decorator
+from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT
_LOGGER = logging.getLogger(__name__)
-ATTR_ADB_RESPONSE = "adb_response"
-ATTR_DEVICE_PATH = "device_path"
-ATTR_HDMI_INPUT = "hdmi_input"
-ATTR_LOCAL_PATH = "local_path"
-
-SERVICE_ADB_COMMAND = "adb_command"
-SERVICE_DOWNLOAD = "download"
-SERVICE_LEARN_SENDEVENT = "learn_sendevent"
-SERVICE_UPLOAD = "upload"
-
# Translate from `AndroidTV` / `FireTV` reported state to HA state.
ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF,
@@ -77,32 +65,6 @@ async def async_setup_entry(
]
)
- platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(
- SERVICE_ADB_COMMAND,
- {vol.Required(ATTR_COMMAND): cv.string},
- "adb_command",
- )
- platform.async_register_entity_service(
- SERVICE_LEARN_SENDEVENT, None, "learn_sendevent"
- )
- platform.async_register_entity_service(
- SERVICE_DOWNLOAD,
- {
- vol.Required(ATTR_DEVICE_PATH): cv.string,
- vol.Required(ATTR_LOCAL_PATH): cv.string,
- },
- "service_download",
- )
- platform.async_register_entity_service(
- SERVICE_UPLOAD,
- {
- vol.Required(ATTR_DEVICE_PATH): cv.string,
- vol.Required(ATTR_LOCAL_PATH): cv.string,
- },
- "service_upload",
- )
-
class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
"""Representation of an Android or Fire TV device."""
diff --git a/homeassistant/components/androidtv/services.py b/homeassistant/components/androidtv/services.py
new file mode 100644
index 00000000000..8a44399b727
--- /dev/null
+++ b/homeassistant/components/androidtv/services.py
@@ -0,0 +1,66 @@
+"""Services for Android/Fire TV devices."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
+from homeassistant.const import ATTR_COMMAND
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv, service
+
+from .const import DOMAIN
+
+ATTR_ADB_RESPONSE = "adb_response"
+ATTR_DEVICE_PATH = "device_path"
+ATTR_HDMI_INPUT = "hdmi_input"
+ATTR_LOCAL_PATH = "local_path"
+
+SERVICE_ADB_COMMAND = "adb_command"
+SERVICE_DOWNLOAD = "download"
+SERVICE_LEARN_SENDEVENT = "learn_sendevent"
+SERVICE_UPLOAD = "upload"
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register the Android TV / Fire TV services."""
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_ADB_COMMAND,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={vol.Required(ATTR_COMMAND): cv.string},
+ func="adb_command",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_LEARN_SENDEVENT,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema=None,
+ func="learn_sendevent",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_DOWNLOAD,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={
+ vol.Required(ATTR_DEVICE_PATH): cv.string,
+ vol.Required(ATTR_LOCAL_PATH): cv.string,
+ },
+ func="service_download",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_UPLOAD,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={
+ vol.Required(ATTR_DEVICE_PATH): cv.string,
+ vol.Required(ATTR_LOCAL_PATH): cv.string,
+ },
+ func="service_upload",
+ )
diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py
index 0a236c7c9ef..9e7b30afa13 100644
--- a/homeassistant/components/androidtv_remote/config_flow.py
+++ b/homeassistant/components/androidtv_remote/config_flow.py
@@ -37,7 +37,7 @@ from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
_LOGGER = logging.getLogger(__name__)
-APPS_NEW_ID = "NewApp"
+APPS_NEW_ID = "add_new"
CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id"
@@ -66,9 +66,14 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self.host = user_input[CONF_HOST]
api = create_api(self.hass, self.host, enable_ime=False)
+ await api.async_generate_cert_if_missing()
try:
- await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac()
+ except CannotConnect:
+ # Likely invalid IP address or device is network unreachable. Stay
+ # in the user step allowing the user to enter a different host.
+ errors["base"] = "cannot_connect"
+ else:
await self.async_set_unique_id(format_mac(self.mac))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
@@ -81,11 +86,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
- return await self._async_start_pair()
- except (CannotConnect, ConnectionClosed):
- # Likely invalid IP address or device is network unreachable. Stay
- # in the user step allowing the user to enter a different host.
- errors["base"] = "cannot_connect"
+ try:
+ return await self._async_start_pair()
+ except (CannotConnect, ConnectionClosed):
+ errors["base"] = "cannot_connect"
else:
user_input = {}
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
@@ -112,22 +116,9 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the pair step."""
errors: dict[str, str] = {}
if user_input is not None:
+ pin = user_input["pin"]
try:
- pin = user_input["pin"]
await self.api.async_finish_pairing(pin)
- if self.source == SOURCE_REAUTH:
- return self.async_update_reload_and_abort(
- self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
- )
-
- return self.async_create_entry(
- title=self.name,
- data={
- CONF_HOST: self.host,
- CONF_NAME: self.name,
- CONF_MAC: self.mac,
- },
- )
except InvalidAuth:
# Invalid PIN. Stay in the pair step allowing the user to enter
# a different PIN.
@@ -145,6 +136,20 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
+ else:
+ if self.source == SOURCE_REAUTH:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
+ )
+
+ return self.async_create_entry(
+ title=self.name,
+ data={
+ CONF_HOST: self.host,
+ CONF_NAME: self.name,
+ CONF_MAC: self.mac,
+ },
+ )
return self.async_show_form(
step_id="pair",
data_schema=STEP_PAIR_DATA_SCHEMA,
@@ -282,7 +287,9 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload):
{
vol.Optional(CONF_APPS): SelectSelector(
SelectSelectorConfig(
- options=apps, mode=SelectSelectorMode.DROPDOWN
+ options=apps,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="apps",
)
),
vol.Required(
diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py
index 7a1e2d6bf06..a006118afff 100644
--- a/homeassistant/components/androidtv_remote/entity.py
+++ b/homeassistant/components/androidtv_remote/entity.py
@@ -6,7 +6,7 @@ from typing import Any
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
-from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
+from homeassistant.const import CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -28,8 +28,6 @@ class AndroidTVRemoteBaseEntity(Entity):
) -> None:
"""Initialize the entity."""
self._api = api
- self._host = config_entry.data[CONF_HOST]
- self._name = config_entry.data[CONF_NAME]
self._apps: dict[str, Any] = config_entry.options.get(CONF_APPS, {})
self._attr_unique_id = config_entry.unique_id
self._attr_is_on = api.is_on
@@ -39,7 +37,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
identifiers={(DOMAIN, config_entry.unique_id)},
- name=self._name,
+ name=config_entry.data[CONF_NAME],
manufacturer=device_info["manufacturer"],
model=device_info["model"],
)
diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json
index 9f41d8230c6..822f514ca7c 100644
--- a/homeassistant/components/androidtv_remote/manifest.json
+++ b/homeassistant/components/androidtv_remote/manifest.json
@@ -7,6 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
+ "quality_scale": "platinum",
"requirements": ["androidtvremote2==0.2.3"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py
index e4f653cbcf1..371c97cc33e 100644
--- a/homeassistant/components/androidtv_remote/media_player.py
+++ b/homeassistant/components/androidtv_remote/media_player.py
@@ -175,7 +175,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
"""Play a piece of media."""
if media_type == MediaType.CHANNEL:
if not media_id.isnumeric():
- raise ValueError(f"Channel must be numeric: {media_id}")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_channel",
+ translation_placeholders={"media_id": media_id},
+ )
if self._channel_set_task:
self._channel_set_task.cancel()
self._channel_set_task = asyncio.create_task(
@@ -188,7 +192,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
self._send_launch_app_command(media_id)
return
- raise ValueError(f"Invalid media type: {media_type}")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_media_type",
+ translation_placeholders={"media_type": media_type},
+ )
async def async_browse_media(
self,
diff --git a/homeassistant/components/androidtv_remote/quality_scale.yaml b/homeassistant/components/androidtv_remote/quality_scale.yaml
new file mode 100644
index 00000000000..7669f4c4165
--- /dev/null
+++ b/homeassistant/components/androidtv_remote/quality_scale.yaml
@@ -0,0 +1,78 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No integration-specific service actions are defined.
+ appropriate-polling:
+ status: exempt
+ comment: This is a push-based integration.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: The integration is configured on a per-device basis, so there are no dynamic devices to add.
+ entity-category:
+ status: exempt
+ comment: All entities are primary and do not require a specific category.
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: The integration provides only primary entities that should be enabled.
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: Icons are provided by the entity's device class, and no state-based icons are needed.
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: The integration uses the reauth flow for authentication issues, and no other repairable issues have been identified.
+ stale-devices:
+ status: exempt
+ comment: The integration manages a single device per config entry. Stale device removal is handled by removing the config entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: The underlying library does not use HTTP for communication.
+ strict-typing: done
diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json
index d0eb1d0dca4..971ee477b74 100644
--- a/homeassistant/components/androidtv_remote/strings.json
+++ b/homeassistant/components/androidtv_remote/strings.json
@@ -22,7 +22,7 @@
},
"zeroconf_confirm": {
"title": "Discovered Android TV",
- "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
+ "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
},
"pair": {
"description": "Enter the pairing code displayed on the Android TV ({name}).",
@@ -85,6 +85,19 @@
"exceptions": {
"connection_closed": {
"message": "Connection to the Android TV device is closed"
+ },
+ "invalid_channel": {
+ "message": "Channel must be numeric: {media_id}"
+ },
+ "invalid_media_type": {
+ "message": "Invalid media type: {media_type}"
+ }
+ },
+ "selector": {
+ "apps": {
+ "options": {
+ "add_new": "Add new"
+ }
}
}
}
diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py
index b996b7d38c5..55178d101fb 100644
--- a/homeassistant/components/anthropic/__init__.py
+++ b/homeassistant/components/anthropic/__init__.py
@@ -129,9 +129,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
- # Device and entity registries don't update the disabled_by flag
- # when moving a device or entity from one config entry to another,
- # so we need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to DEVICE or USER instead,
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
@@ -146,9 +146,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if device is not None:
- # Device and entity registries don't update the disabled_by flag when
- # moving a device or entity from one config entry to another, so we
- # need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to USER instead,
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py
index 356140ff66e..395f7fa8a81 100644
--- a/homeassistant/components/anthropic/const.py
+++ b/homeassistant/components/anthropic/const.py
@@ -19,9 +19,8 @@ CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024
-THINKING_MODELS = [
- "claude-3-7-sonnet",
- "claude-sonnet-4-0",
- "claude-opus-4-0",
- "claude-opus-4-1",
+NON_THINKING_MODELS = [
+ "claude-3-5", # Both sonnet and haiku
+ "claude-3-opus",
+ "claude-3-haiku",
]
diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py
index 7338cbe2906..7c58326515e 100644
--- a/homeassistant/components/anthropic/entity.py
+++ b/homeassistant/components/anthropic/entity.py
@@ -51,11 +51,11 @@ from .const import (
DOMAIN,
LOGGER,
MIN_THINKING_BUDGET,
+ NON_THINKING_MODELS,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
- THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
@@ -364,7 +364,7 @@ class AnthropicBaseLLMEntity(Entity):
if tools:
model_args["tools"] = tools
if (
- model.startswith(tuple(THINKING_MODELS))
+ not model.startswith(tuple(NON_THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json
index 6fed0282a00..a0991f42fdb 100644
--- a/homeassistant/components/anthropic/manifest.json
+++ b/homeassistant/components/anthropic/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["anthropic==0.62.0"]
+ "requirements": ["anthropic==0.69.0"]
}
diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py
index 7593365c573..210993b2203 100644
--- a/homeassistant/components/aosmith/__init__.py
+++ b/homeassistant/components/aosmith/__init__.py
@@ -16,7 +16,7 @@ from .coordinator import (
AOSmithStatusCoordinator,
)
-PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER]
+PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER]
async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool:
diff --git a/homeassistant/components/aosmith/icons.json b/homeassistant/components/aosmith/icons.json
index e31a68464ce..a7dcfc4adc9 100644
--- a/homeassistant/components/aosmith/icons.json
+++ b/homeassistant/components/aosmith/icons.json
@@ -1,5 +1,10 @@
{
"entity": {
+ "select": {
+ "hot_water_plus_level": {
+ "default": "mdi:water-plus"
+ }
+ },
"sensor": {
"hot_water_availability": {
"default": "mdi:water-thermometer"
diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json
index a928a6677cb..bcc8d6859a0 100644
--- a/homeassistant/components/aosmith/manifest.json
+++ b/homeassistant/components/aosmith/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling",
- "requirements": ["py-aosmith==1.0.12"]
+ "requirements": ["py-aosmith==1.0.14"]
}
diff --git a/homeassistant/components/aosmith/select.py b/homeassistant/components/aosmith/select.py
new file mode 100644
index 00000000000..e85bd8b702a
--- /dev/null
+++ b/homeassistant/components/aosmith/select.py
@@ -0,0 +1,70 @@
+"""The select platform for the A. O. Smith integration."""
+
+from homeassistant.components.select import SelectEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import AOSmithConfigEntry
+from .coordinator import AOSmithStatusCoordinator
+from .entity import AOSmithStatusEntity
+
+HWP_LEVEL_HA_TO_AOSMITH = {
+ "off": 0,
+ "level1": 1,
+ "level2": 2,
+ "level3": 3,
+}
+HWP_LEVEL_AOSMITH_TO_HA = {value: key for key, value in HWP_LEVEL_HA_TO_AOSMITH.items()}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: AOSmithConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up A. O. Smith select platform."""
+ data = entry.runtime_data
+
+ async_add_entities(
+ AOSmithHotWaterPlusSelectEntity(data.status_coordinator, device.junction_id)
+ for device in data.status_coordinator.data.values()
+ if device.supports_hot_water_plus
+ )
+
+
+class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity):
+ """Class for the Hot Water+ select entity."""
+
+ _attr_translation_key = "hot_water_plus_level"
+ _attr_options = list(HWP_LEVEL_HA_TO_AOSMITH)
+
+ def __init__(self, coordinator: AOSmithStatusCoordinator, junction_id: str) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator, junction_id)
+ self._attr_unique_id = f"hot_water_plus_level_{junction_id}"
+
+ @property
+ def suggested_object_id(self) -> str | None:
+ """Override the suggested object id to make '+' get converted to 'plus' in the entity id."""
+ return "hot_water_plus_level"
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current Hot Water+ mode."""
+ hot_water_plus_level = self.device.status.hot_water_plus_level
+ return (
+ None
+ if hot_water_plus_level is None
+ else HWP_LEVEL_AOSMITH_TO_HA.get(hot_water_plus_level)
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Set the Hot Water+ mode."""
+ aosmith_hwp_level = HWP_LEVEL_HA_TO_AOSMITH[option]
+ await self.client.update_mode(
+ junction_id=self.junction_id,
+ mode=self.device.status.current_mode,
+ hot_water_plus_level=aosmith_hwp_level,
+ )
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json
index c88b9cab783..fa2d5a67020 100644
--- a/homeassistant/components/aosmith/strings.json
+++ b/homeassistant/components/aosmith/strings.json
@@ -26,6 +26,17 @@
}
},
"entity": {
+ "select": {
+ "hot_water_plus_level": {
+ "name": "Hot Water+ level",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "level1": "Level 1",
+ "level2": "Level 2",
+ "level3": "Level 3"
+ }
+ }
+ },
"sensor": {
"hot_water_availability": {
"name": "Hot water availability"
diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py
index e444f1cd735..7526d605c59 100644
--- a/homeassistant/components/apcupsd/__init__.py
+++ b/homeassistant/components/apcupsd/__init__.py
@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
-PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR)
+PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(
diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py
index 505543e0936..fb9d31764cc 100644
--- a/homeassistant/components/apcupsd/coordinator.py
+++ b/homeassistant/components/apcupsd/coordinator.py
@@ -100,6 +100,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
name=self.data.name or "APC UPS",
hw_version=self.data.get("FIRMWARE"),
sw_version=self.data.get("VERSION"),
+ serial_number=self.data.serial_no,
)
async def _async_update_data(self) -> APCUPSdData:
diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json
index 5e5a81c358a..e0aff037d9e 100644
--- a/homeassistant/components/apcupsd/manifest.json
+++ b/homeassistant/components/apcupsd/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
- "quality_scale": "bronze",
- "requirements": ["aioapcaccess==0.4.2"]
+ "quality_scale": "platinum",
+ "requirements": ["aioapcaccess==1.0.0"]
}
diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml
index 23b72134d34..3d19814fa48 100644
--- a/homeassistant/components/apcupsd/quality_scale.yaml
+++ b/homeassistant/components/apcupsd/quality_scale.yaml
@@ -43,10 +43,7 @@ rules:
status: exempt
comment: |
The integration does not require authentication.
- test-coverage:
- status: todo
- comment: |
- Patch `aioapcaccess.request_status` where we use it.
+ test-coverage: done
# Gold
devices: done
diagnostics: done
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index 14baed5bfce..3a18bea1a8a 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -395,6 +395,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"upsmode": SensorEntityDescription(
key="upsmode",
translation_key="ups_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
),
"upsname": SensorEntityDescription(
key="upsname",
@@ -466,7 +467,10 @@ async def async_setup_entry(
# periodical (or manual) self test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it would ever be available. Here we
# add it anyway and mark it as unknown initially.
- for resource in available_resources | {LAST_S_TEST}:
+ #
+ # We also sort the resources to ensure the order of entities created is deterministic since
+ # "APCMODEL" and "MODEL" resources map to the same "Model" name.
+ for resource in sorted(available_resources | {LAST_S_TEST}):
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
diff --git a/homeassistant/components/aps/__init__.py b/homeassistant/components/aps/__init__.py
deleted file mode 100644
index 7af88840958..00000000000
--- a/homeassistant/components/aps/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Virtual integration: Arizona Public Service (APS)."""
diff --git a/homeassistant/components/aps/manifest.json b/homeassistant/components/aps/manifest.json
deleted file mode 100644
index 347fd74a7bf..00000000000
--- a/homeassistant/components/aps/manifest.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "domain": "aps",
- "name": "Arizona Public Service (APS)",
- "integration_type": "virtual",
- "supported_by": "opower"
-}
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index 8f4c6efd355..e9394a8ac5d 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -103,6 +103,7 @@ async def async_pipeline_from_audio_stream(
wake_word_settings: WakeWordSettings | None = None,
audio_settings: AudioSettings | None = None,
device_id: str | None = None,
+ satellite_id: str | None = None,
start_stage: PipelineStage = PipelineStage.STT,
end_stage: PipelineStage = PipelineStage.TTS,
conversation_extra_system_prompt: str | None = None,
@@ -115,6 +116,7 @@ async def async_pipeline_from_audio_stream(
pipeline_input = PipelineInput(
session=session,
device_id=device_id,
+ satellite_id=satellite_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
diff --git a/homeassistant/components/assist_pipeline/acknowledge.mp3 b/homeassistant/components/assist_pipeline/acknowledge.mp3
new file mode 100644
index 00000000000..1709ff20bc2
Binary files /dev/null and b/homeassistant/components/assist_pipeline/acknowledge.mp3 differ
diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py
index 52583cf21a4..54829a48f88 100644
--- a/homeassistant/components/assist_pipeline/const.py
+++ b/homeassistant/components/assist_pipeline/const.py
@@ -1,5 +1,7 @@
"""Constants for the Assist pipeline integration."""
+from pathlib import Path
+
DOMAIN = "assist_pipeline"
DATA_CONFIG = f"{DOMAIN}.config"
@@ -23,3 +25,5 @@ SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
OPTION_PREFERRED = "preferred"
+
+ACKNOWLEDGE_PATH = Path(__file__).parent / "acknowledge.mp3"
diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json
index 3a59d8f87f1..d88e4352130 100644
--- a/homeassistant/components/assist_pipeline/manifest.json
+++ b/homeassistant/components/assist_pipeline/manifest.json
@@ -2,7 +2,7 @@
"domain": "assist_pipeline",
"name": "Assist pipeline",
"after_dependencies": ["repairs"],
- "codeowners": ["@balloob", "@synesthesiam"],
+ "codeowners": ["@synesthesiam", "@arturpragacz"],
"dependencies": ["conversation", "stt", "tts", "wake_word"],
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
"integration_type": "system",
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index 0cd593e9666..764a036bb35 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -23,7 +23,12 @@ from homeassistant.components import conversation, stt, tts, wake_word, websocke
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import chat_session, intent
+from homeassistant.helpers import (
+ chat_session,
+ device_registry as dr,
+ entity_registry as er,
+ intent,
+)
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@@ -45,6 +50,7 @@ from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .const import (
+ ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
@@ -113,6 +119,7 @@ PIPELINE_FIELDS: VolDictType = {
vol.Required("wake_word_entity"): vol.Any(str, None),
vol.Required("wake_word_id"): vol.Any(str, None),
vol.Optional("prefer_local_intents"): bool,
+ vol.Optional("acknowledge_media_id"): str,
}
STORED_PIPELINE_RUNS = 10
@@ -583,6 +590,9 @@ class PipelineRun:
_device_id: str | None = None
"""Optional device id set during run start."""
+ _satellite_id: str | None = None
+ """Optional satellite id set during run start."""
+
_conversation_data: PipelineConversationData | None = None
"""Data tied to the conversation ID."""
@@ -636,9 +646,12 @@ class PipelineRun:
return
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
- def start(self, conversation_id: str, device_id: str | None) -> None:
+ def start(
+ self, conversation_id: str, device_id: str | None, satellite_id: str | None
+ ) -> None:
"""Emit run start event."""
self._device_id = device_id
+ self._satellite_id = satellite_id
self._start_debug_recording_thread()
data: dict[str, Any] = {
@@ -646,6 +659,8 @@ class PipelineRun:
"language": self.language,
"conversation_id": conversation_id,
}
+ if satellite_id is not None:
+ data["satellite_id"] = satellite_id
if self.runner_data is not None:
data["runner_data"] = self.runner_data
if self.tts_stream:
@@ -1057,10 +1072,12 @@ class PipelineRun:
self,
intent_input: str,
conversation_id: str,
- device_id: str | None,
conversation_extra_system_prompt: str | None,
- ) -> str:
- """Run intent recognition portion of pipeline. Returns text to speak."""
+ ) -> tuple[str, bool]:
+ """Run intent recognition portion of pipeline.
+
+ Returns (speech, all_targets_in_satellite_area).
+ """
if self.intent_agent is None or self._conversation_data is None:
raise RuntimeError("Recognize intent was not prepared")
@@ -1088,7 +1105,8 @@ class PipelineRun:
"language": input_language,
"intent_input": intent_input,
"conversation_id": conversation_id,
- "device_id": device_id,
+ "device_id": self._device_id,
+ "satellite_id": self._satellite_id,
"prefer_local_intents": self.pipeline.prefer_local_intents,
},
)
@@ -1099,7 +1117,8 @@ class PipelineRun:
text=intent_input,
context=self.context,
conversation_id=conversation_id,
- device_id=device_id,
+ device_id=self._device_id,
+ satellite_id=self._satellite_id,
language=input_language,
agent_id=self.intent_agent.id,
extra_system_prompt=conversation_extra_system_prompt,
@@ -1107,6 +1126,7 @@ class PipelineRun:
agent_id = self.intent_agent.id
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
+ all_targets_in_satellite_area = False
intent_response: intent.IntentResponse | None = None
if not processed_locally and not self._intent_agent_only:
# Sentence triggers override conversation agent
@@ -1269,6 +1289,7 @@ class PipelineRun:
text=user_input.text,
conversation_id=user_input.conversation_id,
device_id=user_input.device_id,
+ satellite_id=user_input.satellite_id,
context=user_input.context,
language=user_input.language,
agent_id=user_input.agent_id,
@@ -1280,6 +1301,19 @@ class PipelineRun:
if tts_input_stream and self._streamed_response_text:
tts_input_stream.put_nowait(None)
+ if agent_id == conversation.HOME_ASSISTANT_AGENT:
+ # Check if all targeted entities were in the same area as
+ # the satellite device.
+ # If so, the satellite should respond with an acknowledge beep
+ # instead of a full response.
+ all_targets_in_satellite_area = (
+ self._get_all_targets_in_satellite_area(
+ conversation_result.response,
+ self._satellite_id,
+ self._device_id,
+ )
+ )
+
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(
@@ -1302,7 +1336,68 @@ class PipelineRun:
if conversation_result.continue_conversation:
self._conversation_data.continue_conversation_agent = agent_id
- return speech
+ return (speech, all_targets_in_satellite_area)
+
+ def _get_all_targets_in_satellite_area(
+ self,
+ intent_response: intent.IntentResponse,
+ satellite_id: str | None,
+ device_id: str | None,
+ ) -> bool:
+ """Return true if all targeted entities were in the same area as the device."""
+ if (
+ intent_response.response_type != intent.IntentResponseType.ACTION_DONE
+ or not intent_response.matched_states
+ ):
+ return False
+
+ entity_registry = er.async_get(self.hass)
+ device_registry = dr.async_get(self.hass)
+
+ area_id: str | None = None
+
+ if (
+ satellite_id is not None
+ and (target_entity_entry := entity_registry.async_get(satellite_id))
+ is not None
+ ):
+ area_id = target_entity_entry.area_id
+ device_id = target_entity_entry.device_id
+
+ if area_id is None:
+ if device_id is None:
+ return False
+
+ device_entry = device_registry.async_get(device_id)
+ if device_entry is None:
+ return False
+
+ area_id = device_entry.area_id
+ if area_id is None:
+ return False
+
+ for state in intent_response.matched_states:
+ target_entity_entry = entity_registry.async_get(state.entity_id)
+ if target_entity_entry is None:
+ return False
+
+ target_area_id = target_entity_entry.area_id
+ if target_area_id is None:
+ if target_entity_entry.device_id is None:
+ return False
+
+ target_device_entry = device_registry.async_get(
+ target_entity_entry.device_id
+ )
+ if target_device_entry is None:
+ return False
+
+ target_area_id = target_device_entry.area_id
+
+ if target_area_id != area_id:
+ return False
+
+ return True
async def prepare_text_to_speech(self) -> None:
"""Prepare text-to-speech."""
@@ -1340,7 +1435,9 @@ class PipelineRun:
),
) from err
- async def text_to_speech(self, tts_input: str) -> None:
+ async def text_to_speech(
+ self, tts_input: str, override_media_path: Path | None = None
+ ) -> None:
"""Run text-to-speech portion of pipeline."""
assert self.tts_stream is not None
@@ -1352,11 +1449,14 @@ class PipelineRun:
"language": self.pipeline.tts_language,
"voice": self.pipeline.tts_voice,
"tts_input": tts_input,
+ "acknowledge_override": override_media_path is not None,
},
)
)
- if not self._streamed_response_text:
+ if override_media_path:
+ self.tts_stream.async_override_result(override_media_path)
+ elif not self._streamed_response_text:
self.tts_stream.async_set_message(tts_input)
tts_output = {
@@ -1567,10 +1667,15 @@ class PipelineInput:
device_id: str | None = None
"""Identifier of the device that is processing the input/output of the pipeline."""
+ satellite_id: str | None = None
+ """Identifier of the satellite that is processing the input/output of the pipeline."""
+
async def execute(self) -> None:
"""Run pipeline."""
self.run.start(
- conversation_id=self.session.conversation_id, device_id=self.device_id
+ conversation_id=self.session.conversation_id,
+ device_id=self.device_id,
+ satellite_id=self.satellite_id,
)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
@@ -1649,17 +1754,20 @@ class PipelineInput:
if self.run.end_stage != PipelineStage.STT:
tts_input = self.tts_input
+ all_targets_in_satellite_area = False
if current_stage == PipelineStage.INTENT:
# intent-recognition
assert intent_input is not None
- tts_input = await self.run.recognize_intent(
+ (
+ tts_input,
+ all_targets_in_satellite_area,
+ ) = await self.run.recognize_intent(
intent_input,
self.session.conversation_id,
- self.device_id,
self.conversation_extra_system_prompt,
)
- if tts_input.strip():
+ if all_targets_in_satellite_area or tts_input.strip():
current_stage = PipelineStage.TTS
else:
# Skip TTS
@@ -1668,8 +1776,14 @@ class PipelineInput:
if self.run.end_stage != PipelineStage.INTENT:
# text-to-speech
if current_stage == PipelineStage.TTS:
- assert tts_input is not None
- await self.run.text_to_speech(tts_input)
+ if all_targets_in_satellite_area:
+ # Use acknowledge media instead of full response
+ await self.run.text_to_speech(
+ tts_input or "", override_media_path=ACKNOWLEDGE_PATH
+ )
+ else:
+ assert tts_input is not None
+ await self.run.text_to_speech(tts_input)
except PipelineError as err:
self.run.process_event(
diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py
index a590f30fc7a..0dabfc2336c 100644
--- a/homeassistant/components/assist_pipeline/select.py
+++ b/homeassistant/components/assist_pipeline/select.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Iterable
+from dataclasses import replace
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, Platform
@@ -64,15 +65,36 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
translation_key="pipeline",
entity_category=EntityCategory.CONFIG,
)
+
_attr_should_poll = False
_attr_current_option = OPTION_PREFERRED
_attr_options = [OPTION_PREFERRED]
- def __init__(self, hass: HomeAssistant, domain: str, unique_id_prefix: str) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ domain: str,
+ unique_id_prefix: str,
+ index: int = 0,
+ ) -> None:
"""Initialize a pipeline selector."""
+ if index < 1:
+ # Keep compatibility
+ key_suffix = ""
+ placeholder = ""
+ else:
+ key_suffix = f"_{index + 1}"
+ placeholder = f" {index + 1}"
+
+ self.entity_description = replace(
+ self.entity_description,
+ key=f"pipeline{key_suffix}",
+ translation_placeholders={"index": placeholder},
+ )
+
self._domain = domain
self._unique_id_prefix = unique_id_prefix
- self._attr_unique_id = f"{unique_id_prefix}-pipeline"
+ self._attr_unique_id = f"{unique_id_prefix}-{self.entity_description.key}"
self.hass = hass
self._update_options()
@@ -87,7 +109,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
)
state = await self.async_get_last_state()
- if state is not None and state.state in self.options:
+ if (state is not None) and (state.state in self.options):
self._attr_current_option = state.state
if self.registry_entry and (device_id := self.registry_entry.device_id):
@@ -97,7 +119,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
def cleanup() -> None:
"""Clean up registered device."""
- pipeline_data.pipeline_devices.pop(device_id)
+ pipeline_data.pipeline_devices.pop(device_id, None)
self.async_on_remove(cleanup)
diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json
index 804d43c3a0a..abcd6cbd21e 100644
--- a/homeassistant/components/assist_pipeline/strings.json
+++ b/homeassistant/components/assist_pipeline/strings.json
@@ -7,7 +7,7 @@
},
"select": {
"pipeline": {
- "name": "Assistant",
+ "name": "Assistant{index}",
"state": {
"preferred": "Preferred"
}
diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py
index 3d562544c68..05c0db776a3 100644
--- a/homeassistant/components/assist_satellite/entity.py
+++ b/homeassistant/components/assist_satellite/entity.py
@@ -522,6 +522,7 @@ class AssistSatelliteEntity(entity.Entity):
pipeline_id=self._resolve_pipeline(),
conversation_id=session.conversation_id,
device_id=device_id,
+ satellite_id=self.entity_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py
index 7612753e8c4..24958b36153 100644
--- a/homeassistant/components/assist_satellite/intent.py
+++ b/homeassistant/components/assist_satellite/intent.py
@@ -75,7 +75,6 @@ class BroadcastIntentHandler(intent.IntentHandler):
)
response = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
success_results=[
intent.IntentResponseTarget(
diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json
index 184de576050..5164df9d808 100644
--- a/homeassistant/components/assist_satellite/manifest.json
+++ b/homeassistant/components/assist_satellite/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "assist_satellite",
"name": "Assist Satellite",
- "codeowners": ["@home-assistant/core", "@synesthesiam"],
+ "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
- "requirements": ["hassil==3.1.0"]
+ "requirements": ["hassil==3.2.0"]
}
diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py
index 6e33f3a0b43..ae6cbc1c82a 100644
--- a/homeassistant/components/asuswrt/bridge.py
+++ b/homeassistant/components/asuswrt/bridge.py
@@ -12,6 +12,7 @@ from typing import Any, cast
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
from aiohttp import ClientSession
from asusrouter import AsusRouter, AsusRouterError
+from asusrouter.config import ARConfigKey
from asusrouter.modules.client import AsusClient
from asusrouter.modules.data import AsusData
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
@@ -119,10 +120,18 @@ class AsusWrtBridge(ABC):
def __init__(self, host: str) -> None:
"""Initialize Bridge."""
+ self._configuration_url = f"http://{host}"
self._host = host
self._firmware: str | None = None
self._label_mac: str | None = None
self._model: str | None = None
+ self._model_id: str | None = None
+ self._serial_number: str | None = None
+
+ @property
+ def configuration_url(self) -> str:
+ """Return configuration URL."""
+ return self._configuration_url
@property
def host(self) -> str:
@@ -144,6 +153,16 @@ class AsusWrtBridge(ABC):
"""Return model information."""
return self._model
+ @property
+ def model_id(self) -> str | None:
+ """Return model_id information."""
+ return self._model_id
+
+ @property
+ def serial_number(self) -> str | None:
+ """Return serial number information."""
+ return self._serial_number
+
@property
@abstractmethod
def is_connected(self) -> bool:
@@ -314,10 +333,14 @@ class AsusWrtHttpBridge(AsusWrtBridge):
def __init__(self, conf: dict[str, Any], session: ClientSession) -> None:
"""Initialize Bridge that use HTTP library."""
super().__init__(conf[CONF_HOST])
- self._api = self._get_api(conf, session)
+ # Get API configuration
+ config = self._get_api_config()
+ self._api = self._get_api(conf, session, config)
@staticmethod
- def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusRouter:
+ def _get_api(
+ conf: dict[str, Any], session: ClientSession, config: dict[ARConfigKey, Any]
+ ) -> AsusRouter:
"""Get the AsusRouter API."""
return AsusRouter(
hostname=conf[CONF_HOST],
@@ -326,8 +349,19 @@ class AsusWrtHttpBridge(AsusWrtBridge):
use_ssl=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS,
port=conf.get(CONF_PORT),
session=session,
+ config=config,
)
+ def _get_api_config(self) -> dict[ARConfigKey, Any]:
+ """Get configuration for the API."""
+ return {
+ # Enable automatic temperature data correction in the library
+ ARConfigKey.OPTIMISTIC_TEMPERATURE: True,
+ # Disable `warning`-level log message when temperature
+ # is corrected by setting it to already notified.
+ ARConfigKey.NOTIFIED_OPTIMISTIC_TEMPERATURE: True,
+ }
+
@property
def is_connected(self) -> bool:
"""Get connected status."""
@@ -343,8 +377,11 @@ class AsusWrtHttpBridge(AsusWrtBridge):
# get main router properties
if mac := _identity.mac:
self._label_mac = format_mac(mac)
+ self._configuration_url = self._api.webpanel
self._firmware = str(_identity.firmware)
self._model = _identity.model
+ self._model_id = _identity.product_id
+ self._serial_number = _identity.serial
async def async_disconnect(self) -> None:
"""Disconnect to the device."""
diff --git a/homeassistant/components/asuswrt/helpers.py b/homeassistant/components/asuswrt/helpers.py
index 0fb467e6046..65ebedfab4d 100644
--- a/homeassistant/components/asuswrt/helpers.py
+++ b/homeassistant/components/asuswrt/helpers.py
@@ -2,9 +2,7 @@
from __future__ import annotations
-from typing import Any, TypeVar
-
-T = TypeVar("T", dict[str, Any], list[Any], None)
+from typing import Any
TRANSLATION_MAP = {
"wan_rx": "sensor_rx_bytes",
@@ -36,7 +34,7 @@ def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
-def translate_to_legacy(raw: T) -> T:
+def translate_to_legacy[T: (dict[str, Any], list[Any], None)](raw: T) -> T:
"""Translate raw data to legacy format for dicts and lists."""
if raw is None:
diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json
index c5bdb9440f5..6273c77ca78 100644
--- a/homeassistant/components/asuswrt/manifest.json
+++ b/homeassistant/components/asuswrt/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
- "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.20.0"]
+ "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"]
}
diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py
index c777535e242..3631c7a25bb 100644
--- a/homeassistant/components/asuswrt/router.py
+++ b/homeassistant/components/asuswrt/router.py
@@ -388,11 +388,13 @@ class AsusWrtRouter:
def device_info(self) -> DeviceInfo:
"""Return the device information."""
info = DeviceInfo(
+ configuration_url=self._api.configuration_url,
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
name=self.host,
model=self._api.model or "Asus Router",
+ model_id=self._api.model_id,
+ serial_number=self._api.serial_number,
manufacturer="Asus",
- configuration_url=f"http://{self.host}",
)
if self._api.firmware:
info["sw_version"] = self._api.firmware
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
index 4a37149772a..92da05eabd1 100644
--- a/homeassistant/components/august/lock.py
+++ b/homeassistant/components/august/lock.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
import logging
from typing import Any
from aiohttp import ClientResponseError
-from yalexs.activity import ActivityType, ActivityTypes
-from yalexs.lock import Lock, LockStatus
+from yalexs.activity import ActivityType
+from yalexs.lock import Lock, LockOperation, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
@@ -50,30 +49,25 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
- if self._data.push_updates_connected:
- await self._data.async_lock_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_lock)
+ await self._perform_lock_operation(LockOperation.LOCK)
async def async_open(self, **kwargs: Any) -> None:
"""Open/unlatch the device."""
- if self._data.push_updates_connected:
- await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_unlatch)
+ await self._perform_lock_operation(LockOperation.OPEN)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
- if self._data.push_updates_connected:
- await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_unlock)
+ await self._perform_lock_operation(LockOperation.UNLOCK)
- async def _call_lock_operation(
- self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
- ) -> None:
+ async def _perform_lock_operation(self, operation: LockOperation) -> None:
+ """Perform a lock operation."""
try:
- activities = await lock_operation(self._device_id)
+ activities = await self._data.async_operate_lock(
+ self._device_id,
+ operation,
+ self._data.push_updates_connected,
+ self._hyper_bridge,
+ )
except ClientResponseError as err:
if err.status == LOCK_JAMMED_ERR:
self._detail.lock_status = LockStatus.JAMMED
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
index 1a310dd8241..2a247a8507f 100644
--- a/homeassistant/components/august/manifest.json
+++ b/homeassistant/components/august/manifest.json
@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
- "requirements": ["yalexs==8.12.0", "yalexs-ble==3.1.2"]
+ "requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
}
diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py
index 69ae3eb65bd..675c2d10fea 100644
--- a/homeassistant/components/auth/login_flow.py
+++ b/homeassistant/components/auth/login_flow.py
@@ -92,7 +92,11 @@ from homeassistant.components.http.ban import (
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.network import is_cloud_connection
+from homeassistant.helpers.network import (
+ NoURLAvailableError,
+ get_url,
+ is_cloud_connection,
+)
from homeassistant.util.network import is_local
from . import indieauth
@@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
"""Return the well known OAuth2 authorization info."""
+ hass = request.app[KEY_HASS]
+ # Some applications require absolute urls, so we prefer using the
+ # current requests url if possible, with fallback to a relative url.
+ try:
+ url_prefix = get_url(hass, require_current_request=True)
+ except NoURLAvailableError:
+ url_prefix = ""
return self.json(
{
- "authorization_endpoint": "/auth/authorize",
- "token_endpoint": "/auth/token",
- "revocation_endpoint": "/auth/revoke",
+ "authorization_endpoint": f"{url_prefix}/auth/authorize",
+ "token_endpoint": f"{url_prefix}/auth/token",
+ "revocation_endpoint": f"{url_prefix}/auth/revoke",
"response_types_supported": ["code"],
"service_documentation": (
"https://developers.home-assistant.io/docs/auth_api"
diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py
index 528c658eff1..e3e5f1f97fc 100644
--- a/homeassistant/components/awair/__init__.py
+++ b/homeassistant/components/awair/__init__.py
@@ -26,9 +26,6 @@ async def async_setup_entry(
if CONF_HOST in config_entry.data:
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
- config_entry.async_on_unload(
- config_entry.add_update_listener(_async_update_listener)
- )
else:
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
@@ -36,6 +33,11 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator
+ if CONF_HOST in config_entry.data:
+ config_entry.async_on_unload(
+ config_entry.add_update_listener(_async_update_listener)
+ )
+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py
index 11d8199bdc5..b40ea76cd59 100644
--- a/homeassistant/components/backup/http.py
+++ b/homeassistant/components/backup/http.py
@@ -8,7 +8,7 @@ import threading
from typing import IO, cast
from aiohttp import BodyPartReader
-from aiohttp.hdrs import CONTENT_DISPOSITION
+from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
from aiohttp.web import FileResponse, Request, Response, StreamResponse
from multidict import istr
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import frame
from homeassistant.util import slugify
+from homeassistant.util.async_iterator import AsyncIteratorReader, AsyncIteratorWriter
from . import util
from .agent import BackupAgent
@@ -76,7 +77,8 @@ class DownloadBackupView(HomeAssistantView):
return Response(status=HTTPStatus.NOT_FOUND)
headers = {
- CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
+ CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
+ CONTENT_TYPE: "application/x-tar",
}
try:
@@ -143,7 +145,7 @@ class DownloadBackupView(HomeAssistantView):
return Response(status=HTTPStatus.NOT_FOUND)
else:
stream = await agent.async_download_backup(backup_id)
- reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
+ reader = cast(IO[bytes], AsyncIteratorReader(hass.loop, stream))
worker_done_event = asyncio.Event()
@@ -151,7 +153,7 @@ class DownloadBackupView(HomeAssistantView):
"""Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set)
- stream = util.AsyncIteratorWriter(hass)
+ stream = AsyncIteratorWriter(hass.loop)
worker = threading.Thread(
target=util.decrypt_backup,
args=[backup, reader, stream, password, on_done, 0, []],
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index f1b2f7d5b97..cba09a078c1 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -38,6 +38,7 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
+from homeassistant.util.async_iterator import AsyncIteratorReader
from . import util as backup_util
from .agent import (
@@ -72,7 +73,6 @@ from .models import (
)
from .store import BackupStore
from .util import (
- AsyncIteratorReader,
DecryptedBackupStreamer,
EncryptedBackupStreamer,
make_backup_dir,
@@ -896,7 +896,8 @@ class BackupManager:
)
agent_errors = {
backup_id: error
- for backup_id, error in zip(backup_ids, delete_results, strict=True)
+ for backup_id, error_dict in zip(backup_ids, delete_results, strict=True)
+ for error in error_dict.values()
if error and not isinstance(error, BackupNotFound)
}
if agent_errors:
@@ -1524,7 +1525,7 @@ class BackupManager:
reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
else:
backup_stream = await agent.async_download_backup(backup_id)
- reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
+ reader = cast(IO[bytes], AsyncIteratorReader(self.hass.loop, backup_stream))
try:
await self.hass.async_add_executor_job(
validate_password_stream, reader, password
diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json
index 1b04542dbae..e2a3ad844b8 100644
--- a/homeassistant/components/backup/strings.json
+++ b/homeassistant/components/backup/strings.json
@@ -14,15 +14,15 @@
},
"automatic_backup_failed_addons": {
"title": "Not all add-ons could be included in automatic backup",
- "description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
+ "description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_agents_addons_folders": {
"title": "Automatic backup was created with errors",
- "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
+ "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_folders": {
"title": "Not all folders could be included in automatic backup",
- "description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
+ "description": "Folders {failed_folders} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
}
},
"services": {
diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py
index 1a32c938a54..9dfcb36783d 100644
--- a/homeassistant/components/backup/util.py
+++ b/homeassistant/components/backup/util.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
-from concurrent.futures import CancelledError, Future
import copy
from dataclasses import dataclass, replace
from io import BytesIO
@@ -14,7 +13,7 @@ from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
import threading
-from typing import IO, Any, Self, cast
+from typing import IO, Any, cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
@@ -23,6 +22,11 @@ from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
+from homeassistant.util.async_iterator import (
+ Abort,
+ AsyncIteratorReader,
+ AsyncIteratorWriter,
+)
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER
@@ -59,12 +63,6 @@ class BackupEmpty(DecryptError):
_message = "No tar files found in the backup."
-class AbortCipher(HomeAssistantError):
- """Abort the cipher operation."""
-
- _message = "Abort cipher operation."
-
-
def make_backup_dir(path: Path) -> None:
"""Create a backup directory if it does not exist."""
path.mkdir(exist_ok=True)
@@ -166,106 +164,6 @@ def validate_password(path: Path, password: str | None) -> bool:
return False
-class AsyncIteratorReader:
- """Wrap an AsyncIterator."""
-
- def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
- """Initialize the wrapper."""
- self._aborted = False
- self._hass = hass
- self._stream = stream
- self._buffer: bytes | None = None
- self._next_future: Future[bytes | None] | None = None
- self._pos: int = 0
-
- async def _next(self) -> bytes | None:
- """Get the next chunk from the iterator."""
- return await anext(self._stream, None)
-
- def abort(self) -> None:
- """Abort the reader."""
- self._aborted = True
- if self._next_future is not None:
- self._next_future.cancel()
-
- def read(self, n: int = -1, /) -> bytes:
- """Read data from the iterator."""
- result = bytearray()
- while n < 0 or len(result) < n:
- if not self._buffer:
- self._next_future = asyncio.run_coroutine_threadsafe(
- self._next(), self._hass.loop
- )
- if self._aborted:
- self._next_future.cancel()
- raise AbortCipher
- try:
- self._buffer = self._next_future.result()
- except CancelledError as err:
- raise AbortCipher from err
- self._pos = 0
- if not self._buffer:
- # The stream is exhausted
- break
- chunk = self._buffer[self._pos : self._pos + n]
- result.extend(chunk)
- n -= len(chunk)
- self._pos += len(chunk)
- if self._pos == len(self._buffer):
- self._buffer = None
- return bytes(result)
-
- def close(self) -> None:
- """Close the iterator."""
-
-
-class AsyncIteratorWriter:
- """Wrap an AsyncIterator."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the wrapper."""
- self._aborted = False
- self._hass = hass
- self._pos: int = 0
- self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
- self._write_future: Future[bytes | None] | None = None
-
- def __aiter__(self) -> Self:
- """Return the iterator."""
- return self
-
- async def __anext__(self) -> bytes:
- """Get the next chunk from the iterator."""
- if data := await self._queue.get():
- return data
- raise StopAsyncIteration
-
- def abort(self) -> None:
- """Abort the writer."""
- self._aborted = True
- if self._write_future is not None:
- self._write_future.cancel()
-
- def tell(self) -> int:
- """Return the current position in the iterator."""
- return self._pos
-
- def write(self, s: bytes, /) -> int:
- """Write data to the iterator."""
- self._write_future = asyncio.run_coroutine_threadsafe(
- self._queue.put(s), self._hass.loop
- )
- if self._aborted:
- self._write_future.cancel()
- raise AbortCipher
- try:
- self._write_future.result()
- except CancelledError as err:
- raise AbortCipher from err
- self._pos += len(s)
- return len(s)
-
-
def validate_password_stream(
input_stream: IO[bytes],
password: str | None,
@@ -342,7 +240,7 @@ def decrypt_backup(
finally:
# Write an empty chunk to signal the end of the stream
output_stream.write(b"")
- except AbortCipher:
+ except Abort:
LOGGER.debug("Cipher operation aborted")
finally:
on_done(error)
@@ -430,7 +328,7 @@ def encrypt_backup(
finally:
# Write an empty chunk to signal the end of the stream
output_stream.write(b"")
- except AbortCipher:
+ except Abort:
LOGGER.debug("Cipher operation aborted")
finally:
on_done(error)
@@ -557,8 +455,8 @@ class _CipherBackupStreamer:
self._hass.loop.call_soon_threadsafe(worker_status.done.set)
stream = await self._open_stream()
- reader = AsyncIteratorReader(self._hass, stream)
- writer = AsyncIteratorWriter(self._hass)
+ reader = AsyncIteratorReader(self._hass.loop, stream)
+ writer = AsyncIteratorWriter(self._hass.loop)
worker = threading.Thread(
target=self._cipher_func,
args=[
diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py
index eab2bb3d4e5..34042666ae4 100644
--- a/homeassistant/components/bang_olufsen/__init__.py
+++ b/homeassistant/components/bang_olufsen/__init__.py
@@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
# Add the websocket and API client
entry.runtime_data = BangOlufsenData(websocket, client)
- # Start WebSocket connection
- await client.connect_notifications(remote_control=True, reconnect=True)
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ # Start WebSocket connection once the platforms have been loaded.
+ # This ensures that the initial WebSocket notifications are dispatched to entities
+ await client.connect_notifications(remote_control=True, reconnect=True)
+
return True
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index efb6843356b..583b419eadf 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -125,7 +125,8 @@ async def async_setup_entry(
async_add_entities(
new_entities=[
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
- ]
+ ],
+ update_before_add=True,
)
# Register actions.
@@ -266,34 +267,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._software_status.software_version,
)
- # Get overall device state once. This is handled by WebSocket events the rest of the time.
- product_state = await self._client.get_product_state()
-
- # Get volume information.
- if product_state.volume:
- self._volume = product_state.volume
-
- # Get all playback information.
- # Ensure that the metadata is not None upon startup
- if product_state.playback:
- if product_state.playback.metadata:
- self._playback_metadata = product_state.playback.metadata
- self._remote_leader = product_state.playback.metadata.remote_leader
- if product_state.playback.progress:
- self._playback_progress = product_state.playback.progress
- if product_state.playback.source:
- self._source_change = product_state.playback.source
- if product_state.playback.state:
- self._playback_state = product_state.playback.state
- # Set initial state
- if self._playback_state.value:
- self._state = self._playback_state.value
-
self._attr_media_position_updated_at = utcnow()
- # Get the highest resolution available of the given images.
- self._media_image = get_highest_resolution_artwork(self._playback_metadata)
-
# If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources()
diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml
index 7c3a2d659bd..1a7b1028af9 100644
--- a/homeassistant/components/bang_olufsen/services.yaml
+++ b/homeassistant/components/bang_olufsen/services.yaml
@@ -3,16 +3,12 @@ beolink_allstandby:
entity:
integration: bang_olufsen
domain: media_player
- device:
- integration: bang_olufsen
beolink_expand:
target:
entity:
integration: bang_olufsen
domain: media_player
- device:
- integration: bang_olufsen
fields:
all_discovered:
required: false
@@ -37,8 +33,6 @@ beolink_join:
entity:
integration: bang_olufsen
domain: media_player
- device:
- integration: bang_olufsen
fields:
jid_options:
collapsed: false
@@ -71,16 +65,12 @@ beolink_leave:
entity:
integration: bang_olufsen
domain: media_player
- device:
- integration: bang_olufsen
beolink_unexpand:
target:
entity:
integration: bang_olufsen
domain: media_player
- device:
- integration: bang_olufsen
fields:
jid_options:
collapsed: false
diff --git a/homeassistant/components/bayesian/__init__.py b/homeassistant/components/bayesian/__init__.py
index e6f865b5656..c93f6b7fb0b 100644
--- a/homeassistant/components/bayesian/__init__.py
+++ b/homeassistant/components/bayesian/__init__.py
@@ -1,6 +1,24 @@
"""The bayesian component."""
-from homeassistant.const import Platform
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
-DOMAIN = "bayesian"
-PLATFORMS = [Platform.BINARY_SENSOR]
+from .const import PLATFORMS
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Bayesian from a config entry."""
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload Bayesian config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle options update."""
+ hass.config_entries.async_schedule_reload(entry.entry_id)
diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py
index 32f43983991..6d3dbb7f244 100644
--- a/homeassistant/components/bayesian/binary_sensor.py
+++ b/homeassistant/components/bayesian/binary_sensor.py
@@ -16,6 +16,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ABOVE,
CONF_BELOW,
@@ -32,7 +33,10 @@ from homeassistant.const import (
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import ConditionError, TemplateError
from homeassistant.helpers import condition, config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
TrackTemplate,
TrackTemplateResult,
@@ -44,7 +48,6 @@ from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template, result_as_boolean
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from . import DOMAIN, PLATFORMS
from .const import (
ATTR_OBSERVATIONS,
ATTR_OCCURRED_OBSERVATION_ENTITIES,
@@ -60,6 +63,8 @@ from .const import (
CONF_TO_STATE,
DEFAULT_NAME,
DEFAULT_PROBABILITY_THRESHOLD,
+ DOMAIN,
+ PLATFORMS,
)
from .helpers import Observation
from .issues import raise_mirrored_entries, raise_no_prob_given_false
@@ -67,7 +72,13 @@ from .issues import raise_mirrored_entries, raise_no_prob_given_false
_LOGGER = logging.getLogger(__name__)
-def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
+def above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
+ """Validate above and below options.
+
+ If the observation is of type/platform NUMERIC_STATE, then ensure that the
+ value given for 'above' is not greater than that for 'below'. Also check
+ that at least one of the two is specified.
+ """
if config[CONF_PLATFORM] == CONF_NUMERIC_STATE:
above = config.get(CONF_ABOVE)
below = config.get(CONF_BELOW)
@@ -76,9 +87,7 @@ def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
"For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified",
config[CONF_ENTITY_ID],
)
- raise vol.Invalid(
- "For bayesian numeric state at least one of 'above' or 'below' must be specified."
- )
+ raise vol.Invalid("above_or_below")
if above is not None and below is not None:
if above > below:
_LOGGER.error(
@@ -86,7 +95,7 @@ def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
above,
below,
)
- raise vol.Invalid("'above' is greater than 'below'")
+ raise vol.Invalid("above_below")
return config
@@ -102,11 +111,16 @@ NUMERIC_STATE_SCHEMA = vol.All(
},
required=True,
),
- _above_greater_than_below,
+ above_greater_than_below,
)
-def _no_overlapping(configs: list[dict]) -> list[dict]:
+def no_overlapping(configs: list[dict]) -> list[dict]:
+ """Validate that intervals are not overlapping.
+
+ For a list of observations ensure that there are no overlapping intervals
+ for NUMERIC_STATE observations for the same entity.
+ """
numeric_configs = [
config for config in configs if config[CONF_PLATFORM] == CONF_NUMERIC_STATE
]
@@ -129,11 +143,16 @@ def _no_overlapping(configs: list[dict]) -> list[dict]:
for i, tup in enumerate(intervals):
if len(intervals) > i + 1 and tup.below > intervals[i + 1].above:
+ _LOGGER.error(
+ "Ranges for bayesian numeric state entities must not overlap, but %s has overlapping ranges, above:%s, below:%s overlaps with above:%s, below:%s",
+ ent_id,
+ tup.above,
+ tup.below,
+ intervals[i + 1].above,
+ intervals[i + 1].below,
+ )
raise vol.Invalid(
- "Ranges for bayesian numeric state entities must not overlap, "
- f"but {ent_id} has overlapping ranges, above:{tup.above}, "
- f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, "
- f"below:{intervals[i + 1].below}."
+ "overlapping_ranges",
)
return configs
@@ -168,7 +187,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
vol.All(
cv.ensure_list,
[vol.Any(TEMPLATE_SCHEMA, STATE_SCHEMA, NUMERIC_STATE_SCHEMA)],
- _no_overlapping,
+ no_overlapping,
)
),
vol.Required(CONF_PRIOR): vol.Coerce(float),
@@ -194,9 +213,13 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
- """Set up the Bayesian Binary sensor."""
+ """Set up the Bayesian Binary sensor from a yaml config."""
+ _LOGGER.debug(
+ "Setting up config entry for Bayesian sensor: '%s' with %s observations",
+ config[CONF_NAME],
+ len(config.get(CONF_OBSERVATIONS, [])),
+ )
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
-
name: str = config[CONF_NAME]
unique_id: str | None = config.get(CONF_UNIQUE_ID)
observations: list[ConfigType] = config[CONF_OBSERVATIONS]
@@ -231,6 +254,49 @@ async def async_setup_platform(
)
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the Bayesian Binary sensor from a config entry."""
+ _LOGGER.debug(
+ "Setting up config entry for Bayesian sensor: '%s' with %s observations",
+ config_entry.options[CONF_NAME],
+ len(config_entry.subentries),
+ )
+ config = config_entry.options
+ name: str = config[CONF_NAME]
+ unique_id: str | None = config.get(CONF_UNIQUE_ID, config_entry.entry_id)
+ observations: list[ConfigType] = [
+ dict(subentry.data) for subentry in config_entry.subentries.values()
+ ]
+
+ for observation in observations:
+ if observation[CONF_PLATFORM] == CONF_TEMPLATE:
+ observation[CONF_VALUE_TEMPLATE] = Template(
+ observation[CONF_VALUE_TEMPLATE], hass
+ )
+
+ prior: float = config[CONF_PRIOR]
+ probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
+ device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
+
+ async_add_entities(
+ [
+ BayesianBinarySensor(
+ name,
+ unique_id,
+ prior,
+ observations,
+ probability_threshold,
+ device_class,
+ )
+ ]
+ )
+
+
class BayesianBinarySensor(BinarySensorEntity):
"""Representation of a Bayesian sensor."""
@@ -248,6 +314,7 @@ class BayesianBinarySensor(BinarySensorEntity):
"""Initialize the Bayesian sensor."""
self._attr_name = name
self._attr_unique_id = unique_id and f"bayesian-{unique_id}"
+
self._observations = [
Observation(
entity_id=observation.get(CONF_ENTITY_ID),
@@ -432,21 +499,23 @@ class BayesianBinarySensor(BinarySensorEntity):
1 - observation.prob_given_false,
)
continue
- # observation.observed is None
+ # Entity exists but observation.observed is None
if observation.entity_id is not None:
_LOGGER.debug(
(
"Observation for entity '%s' returned None, it will not be used"
- " for Bayesian updating"
+ " for updating Bayesian sensor '%s'"
),
observation.entity_id,
+ self.entity_id,
)
continue
_LOGGER.debug(
(
"Observation for template entity returned None rather than a valid"
- " boolean, it will not be used for Bayesian updating"
+ " boolean, it will not be used for updating Bayesian sensor '%s'"
),
+ self.entity_id,
)
# the prior has been updated and is now the posterior
return prior
@@ -495,7 +564,6 @@ class BayesianBinarySensor(BinarySensorEntity):
for observation in self._observations:
if observation.value_template is None:
continue
-
template = observation.value_template
observations_by_template.setdefault(template, []).append(observation)
diff --git a/homeassistant/components/bayesian/config_flow.py b/homeassistant/components/bayesian/config_flow.py
new file mode 100644
index 00000000000..ce13cf43d8c
--- /dev/null
+++ b/homeassistant/components/bayesian/config_flow.py
@@ -0,0 +1,646 @@
+"""Config flow for the Bayesian integration."""
+
+from collections.abc import Mapping
+from enum import StrEnum
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ BinarySensorDeviceClass,
+)
+from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN
+from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
+from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
+from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
+from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
+from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
+from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
+from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
+from homeassistant.components.person import DOMAIN as PERSON_DOMAIN
+from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.sun import DOMAIN as SUN_DOMAIN
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
+from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN
+from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
+from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlowResult,
+ ConfigSubentry,
+ ConfigSubentryData,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
+)
+from homeassistant.const import (
+ CONF_ABOVE,
+ CONF_BELOW,
+ CONF_DEVICE_CLASS,
+ CONF_ENTITY_ID,
+ CONF_NAME,
+ CONF_PLATFORM,
+ CONF_STATE,
+ CONF_VALUE_TEMPLATE,
+)
+from homeassistant.core import callback
+from homeassistant.helpers import selector, translation
+from homeassistant.helpers.schema_config_entry_flow import (
+ SchemaCommonFlowHandler,
+ SchemaConfigFlowHandler,
+ SchemaFlowError,
+ SchemaFlowFormStep,
+ SchemaFlowMenuStep,
+)
+
+from .binary_sensor import above_greater_than_below, no_overlapping
+from .const import (
+ CONF_OBSERVATIONS,
+ CONF_P_GIVEN_F,
+ CONF_P_GIVEN_T,
+ CONF_PRIOR,
+ CONF_PROBABILITY_THRESHOLD,
+ CONF_TEMPLATE,
+ CONF_TO_STATE,
+ DEFAULT_NAME,
+ DEFAULT_PROBABILITY_THRESHOLD,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+USER = "user"
+OBSERVATION_SELECTOR = "observation_selector"
+ALLOWED_STATE_DOMAINS = [
+ ALARM_DOMAIN,
+ BINARY_SENSOR_DOMAIN,
+ CALENDAR_DOMAIN,
+ CLIMATE_DOMAIN,
+ COVER_DOMAIN,
+ DEVICE_TRACKER_DOMAIN,
+ INPUT_BOOLEAN_DOMAIN,
+ INPUT_NUMBER_DOMAIN,
+ INPUT_TEXT_DOMAIN,
+ LIGHT_DOMAIN,
+ MEDIA_PLAYER_DOMAIN,
+ NOTIFY_DOMAIN,
+ NUMBER_DOMAIN,
+ PERSON_DOMAIN,
+ "schedule", # Avoids an import that would introduce a dependency.
+ SELECT_DOMAIN,
+ SENSOR_DOMAIN,
+ SUN_DOMAIN,
+ SWITCH_DOMAIN,
+ TODO_DOMAIN,
+ UPDATE_DOMAIN,
+ WEATHER_DOMAIN,
+]
+ALLOWED_NUMERIC_DOMAINS = [
+ SENSOR_DOMAIN,
+ INPUT_NUMBER_DOMAIN,
+ NUMBER_DOMAIN,
+ TODO_DOMAIN,
+ ZONE_DOMAIN,
+]
+
+
+class ObservationTypes(StrEnum):
+ """StrEnum for all the different observation types."""
+
+ STATE = CONF_STATE
+ NUMERIC_STATE = "numeric_state"
+ TEMPLATE = CONF_TEMPLATE
+
+
+class OptionsFlowSteps(StrEnum):
+ """StrEnum for all the different options flow steps."""
+
+ INIT = "init"
+ ADD_OBSERVATION = OBSERVATION_SELECTOR
+
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Required(
+ CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD * 100
+ ): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.SLIDER,
+ step=1.0,
+ min=0,
+ max=100,
+ unit_of_measurement="%",
+ ),
+ ),
+ vol.Range(
+ min=0,
+ max=100,
+ min_included=False,
+ max_included=False,
+ msg="extreme_threshold_error",
+ ),
+ ),
+ vol.Required(CONF_PRIOR, default=DEFAULT_PROBABILITY_THRESHOLD * 100): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.SLIDER,
+ step=1.0,
+ min=0,
+ max=100,
+ unit_of_measurement="%",
+ ),
+ ),
+ vol.Range(
+ min=0,
+ max=100,
+ min_included=False,
+ max_included=False,
+ msg="extreme_prior_error",
+ ),
+ ),
+ vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[cls.value for cls in BinarySensorDeviceClass],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="binary_sensor_device_class",
+ sort=True,
+ ),
+ ),
+ }
+)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(),
+ }
+).extend(OPTIONS_SCHEMA.schema)
+
+OBSERVATION_BOILERPLATE = vol.Schema(
+ {
+ vol.Required(CONF_P_GIVEN_T): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.SLIDER,
+ step=1.0,
+ min=0,
+ max=100,
+ unit_of_measurement="%",
+ ),
+ ),
+ vol.Range(
+ min=0,
+ max=100,
+ min_included=False,
+ max_included=False,
+ msg="extreme_prob_given_error",
+ ),
+ ),
+ vol.Required(CONF_P_GIVEN_F): vol.All(
+ selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.SLIDER,
+ step=1.0,
+ min=0,
+ max=100,
+ unit_of_measurement="%",
+ ),
+ ),
+ vol.Range(
+ min=0,
+ max=100,
+ min_included=False,
+ max_included=False,
+ msg="extreme_prob_given_error",
+ ),
+ ),
+ vol.Required(CONF_NAME): selector.TextSelector(),
+ }
+)
+
+STATE_SUBSCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
+ selector.EntitySelectorConfig(domain=ALLOWED_STATE_DOMAINS)
+ ),
+ vol.Required(CONF_TO_STATE): selector.TextSelector(
+ selector.TextSelectorConfig(
+ multiline=False, type=selector.TextSelectorType.TEXT, multiple=False
+ ) # ideally this would be a state selector context-linked to the above entity.
+ ),
+ },
+).extend(OBSERVATION_BOILERPLATE.schema)
+
+NUMERIC_STATE_SUBSCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
+ selector.EntitySelectorConfig(domain=ALLOWED_NUMERIC_DOMAINS)
+ ),
+ vol.Optional(CONF_ABOVE): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.BOX, step="any"
+ ),
+ ),
+ vol.Optional(CONF_BELOW): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ mode=selector.NumberSelectorMode.BOX, step="any"
+ ),
+ ),
+ },
+).extend(OBSERVATION_BOILERPLATE.schema)
+
+
+TEMPLATE_SUBSCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_VALUE_TEMPLATE): selector.TemplateSelector(
+ selector.TemplateSelectorConfig(),
+ ),
+ },
+).extend(OBSERVATION_BOILERPLATE.schema)
+
+
+def _convert_percentages_to_fractions(
+ data: dict[str, str | float | int],
+) -> dict[str, str | float]:
+ """Convert percentage probability values in a dictionary to fractions for storing in the config entry."""
+ probabilities = [
+ CONF_P_GIVEN_T,
+ CONF_P_GIVEN_F,
+ CONF_PRIOR,
+ CONF_PROBABILITY_THRESHOLD,
+ ]
+ return {
+ key: (
+ value / 100
+ if isinstance(value, (int, float)) and key in probabilities
+ else value
+ )
+ for key, value in data.items()
+ }
+
+
+def _convert_fractions_to_percentages(
+ data: dict[str, str | float],
+) -> dict[str, str | float]:
+ """Convert fraction probability values in a dictionary to percentages for loading into the UI."""
+ probabilities = [
+ CONF_P_GIVEN_T,
+ CONF_P_GIVEN_F,
+ CONF_PRIOR,
+ CONF_PROBABILITY_THRESHOLD,
+ ]
+ return {
+ key: (
+ value * 100
+ if isinstance(value, (int, float)) and key in probabilities
+ else value
+ )
+ for key, value in data.items()
+ }
+
+
+def _select_observation_schema(
+ obs_type: ObservationTypes,
+) -> vol.Schema:
+ """Return the schema for editing the correct observation (SubEntry) type."""
+ if obs_type == str(ObservationTypes.STATE):
+ return STATE_SUBSCHEMA
+ if obs_type == str(ObservationTypes.NUMERIC_STATE):
+ return NUMERIC_STATE_SUBSCHEMA
+
+ return TEMPLATE_SUBSCHEMA
+
+
+async def _get_base_suggested_values(
+ handler: SchemaCommonFlowHandler,
+) -> dict[str, Any]:
+ """Return suggested values for the base sensor options."""
+
+ return _convert_fractions_to_percentages(dict(handler.options))
+
+
+def _get_observation_values_for_editing(
+ subentry: ConfigSubentry,
+) -> dict[str, Any]:
+ """Return the values for editing in the observation subentry."""
+
+ return _convert_fractions_to_percentages(dict(subentry.data))
+
+
+async def _validate_user(
+ handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Modify user input to convert to fractions for storage. Validation is done entirely by the schemas."""
+ user_input = _convert_percentages_to_fractions(user_input)
+ return {**user_input}
+
+
+def _validate_observation_subentry(
+ obs_type: ObservationTypes,
+ user_input: dict[str, Any],
+ other_subentries: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ """Validate an observation input and manually update options with observations as they are nested items."""
+
+ if user_input[CONF_P_GIVEN_T] == user_input[CONF_P_GIVEN_F]:
+ raise SchemaFlowError("equal_probabilities")
+ user_input = _convert_percentages_to_fractions(user_input)
+
+ # Save the observation type in the user input as it is needed in binary_sensor.py
+ user_input[CONF_PLATFORM] = str(obs_type)
+
+ # Additional validation for multiple numeric state observations
+ if (
+ user_input[CONF_PLATFORM] == ObservationTypes.NUMERIC_STATE
+ and other_subentries is not None
+ ):
+ _LOGGER.debug(
+ "Comparing with other subentries: %s", [*other_subentries, user_input]
+ )
+ try:
+ above_greater_than_below(user_input)
+ no_overlapping([*other_subentries, user_input])
+ except vol.Invalid as err:
+ raise SchemaFlowError(err) from err
+
+ _LOGGER.debug("Processed observation with settings: %s", user_input)
+ return user_input
+
+
+async def _validate_subentry_from_config_entry(
+ handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ # Standard behavior is to merge the result with the options.
+ # In this case, we want to add a subentry so we update the options directly.
+ observations: list[dict[str, Any]] = handler.options.setdefault(
+ CONF_OBSERVATIONS, []
+ )
+
+ if handler.parent_handler.cur_step is not None:
+ user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"]
+ user_input = _validate_observation_subentry(
+ user_input[CONF_PLATFORM],
+ user_input,
+ other_subentries=handler.options[CONF_OBSERVATIONS],
+ )
+ observations.append(user_input)
+ return {}
+
+
+async def _get_description_placeholders(
+ handler: SchemaCommonFlowHandler,
+) -> dict[str, str]:
+ # Current step is None when were are about to start the first step
+ if handler.parent_handler.cur_step is None:
+ return {"url": "https://www.home-assistant.io/integrations/bayesian/"}
+ return {
+ "parent_sensor_name": handler.options[CONF_NAME],
+ "device_class_on": translation.async_translate_state(
+ handler.parent_handler.hass,
+ "on",
+ BINARY_SENSOR_DOMAIN,
+ platform=None,
+ translation_key=None,
+ device_class=handler.options.get(CONF_DEVICE_CLASS, None),
+ ),
+ "device_class_off": translation.async_translate_state(
+ handler.parent_handler.hass,
+ "off",
+ BINARY_SENSOR_DOMAIN,
+ platform=None,
+ translation_key=None,
+ device_class=handler.options.get(CONF_DEVICE_CLASS, None),
+ ),
+ }
+
+
+async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]:
+ """Return the menu options for the observation selector."""
+ options = [typ.value for typ in ObservationTypes]
+ if handler.options.get(CONF_OBSERVATIONS):
+ options.append("finish")
+ return options
+
+
+CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = {
+ str(USER): SchemaFlowFormStep(
+ CONFIG_SCHEMA,
+ validate_user_input=_validate_user,
+ next_step=str(OBSERVATION_SELECTOR),
+ description_placeholders=_get_description_placeholders,
+ ),
+ str(OBSERVATION_SELECTOR): SchemaFlowMenuStep(
+ _get_observation_menu_options,
+ ),
+ str(ObservationTypes.STATE): SchemaFlowFormStep(
+ STATE_SUBSCHEMA,
+ next_step=str(OBSERVATION_SELECTOR),
+ validate_user_input=_validate_subentry_from_config_entry,
+ # Prevent the name of the bayesian sensor from being used as the suggested
+ # name of the observations
+ suggested_values=None,
+ description_placeholders=_get_description_placeholders,
+ ),
+ str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep(
+ NUMERIC_STATE_SUBSCHEMA,
+ next_step=str(OBSERVATION_SELECTOR),
+ validate_user_input=_validate_subentry_from_config_entry,
+ suggested_values=None,
+ description_placeholders=_get_description_placeholders,
+ ),
+ str(ObservationTypes.TEMPLATE): SchemaFlowFormStep(
+ TEMPLATE_SUBSCHEMA,
+ next_step=str(OBSERVATION_SELECTOR),
+ validate_user_input=_validate_subentry_from_config_entry,
+ suggested_values=None,
+ description_placeholders=_get_description_placeholders,
+ ),
+ "finish": SchemaFlowFormStep(),
+}
+
+
+OPTIONS_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = {
+ str(OptionsFlowSteps.INIT): SchemaFlowFormStep(
+ OPTIONS_SCHEMA,
+ suggested_values=_get_base_suggested_values,
+ validate_user_input=_validate_user,
+ description_placeholders=_get_description_placeholders,
+ ),
+}
+
+
+class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
+ """Bayesian config flow."""
+
+ VERSION = 1
+ MINOR_VERSION = 1
+
+ config_flow = CONFIG_FLOW
+ options_flow = OPTIONS_FLOW
+
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {"observation": ObservationSubentryFlowHandler}
+
+ def async_config_entry_title(self, options: Mapping[str, str]) -> str:
+ """Return config entry title."""
+ name: str = options[CONF_NAME]
+ return name
+
+ @callback
+ def async_create_entry(
+ self,
+ data: Mapping[str, Any],
+ **kwargs: Any,
+ ) -> ConfigFlowResult:
+ """Finish config flow and create a config entry."""
+ data = dict(data)
+ observations = data.pop(CONF_OBSERVATIONS)
+ subentries: list[ConfigSubentryData] = [
+ ConfigSubentryData(
+ data=observation,
+ title=observation[CONF_NAME],
+ subentry_type="observation",
+ unique_id=None,
+ )
+ for observation in observations
+ ]
+
+ self.async_config_flow_finished(data)
+ return super().async_create_entry(data=data, subentries=subentries, **kwargs)
+
+
+class ObservationSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying a topic."""
+
+ async def step_common(
+ self,
+ user_input: dict[str, Any] | None,
+ obs_type: ObservationTypes,
+ reconfiguring: bool = False,
+ ) -> SubentryFlowResult:
+ """Use common logic within the named steps."""
+
+ errors: dict[str, str] = {}
+
+ other_subentries = None
+ if obs_type == str(ObservationTypes.NUMERIC_STATE):
+ other_subentries = [
+ dict(se.data) for se in self._get_entry().subentries.values()
+ ]
+ # If we are reconfiguring a subentry we don't want to compare with self
+ if reconfiguring:
+ sub_entry = self._get_reconfigure_subentry()
+ if other_subentries is not None:
+ other_subentries.remove(dict(sub_entry.data))
+
+ if user_input is not None:
+ try:
+ user_input = _validate_observation_subentry(
+ obs_type,
+ user_input,
+ other_subentries=other_subentries,
+ )
+ if reconfiguring:
+ return self.async_update_and_abort(
+ self._get_entry(),
+ sub_entry,
+ title=user_input.get(CONF_NAME, sub_entry.data[CONF_NAME]),
+ data_updates=user_input,
+ )
+ return self.async_create_entry(
+ title=user_input.get(CONF_NAME),
+ data=user_input,
+ )
+ except SchemaFlowError as err:
+ errors["base"] = str(err)
+
+ return self.async_show_form(
+ step_id="reconfigure" if reconfiguring else str(obs_type),
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=_select_observation_schema(obs_type),
+ suggested_values=_get_observation_values_for_editing(sub_entry)
+ if reconfiguring
+ else None,
+ ),
+ errors=errors,
+ description_placeholders={
+ "parent_sensor_name": self._get_entry().title,
+ "device_class_on": translation.async_translate_state(
+ self.hass,
+ "on",
+ BINARY_SENSOR_DOMAIN,
+ platform=None,
+ translation_key=None,
+ device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None),
+ ),
+ "device_class_off": translation.async_translate_state(
+ self.hass,
+ "off",
+ BINARY_SENSOR_DOMAIN,
+ platform=None,
+ translation_key=None,
+ device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None),
+ ),
+ },
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add a new observation."""
+
+ return self.async_show_menu(
+ step_id="user",
+ menu_options=[typ.value for typ in ObservationTypes],
+ )
+
+ async def async_step_state(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add a state observation. Function name must be in the format async_step_{observation_type}."""
+
+ return await self.step_common(
+ user_input=user_input, obs_type=ObservationTypes.STATE
+ )
+
+ async def async_step_numeric_state(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add a new numeric state observation, (a numeric range). Function name must be in the format async_step_{observation_type}."""
+
+ return await self.step_common(
+ user_input=user_input, obs_type=ObservationTypes.NUMERIC_STATE
+ )
+
+ async def async_step_template(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add a new template observation. Function name must be in the format async_step_{observation_type}."""
+
+ return await self.step_common(
+ user_input=user_input, obs_type=ObservationTypes.TEMPLATE
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Enable the reconfigure button for observations. Function name must be async_step_reconfigure to be recognised by hass."""
+
+ sub_entry = self._get_reconfigure_subentry()
+
+ return await self.step_common(
+ user_input=user_input,
+ obs_type=ObservationTypes(sub_entry.data[CONF_PLATFORM]),
+ reconfiguring=True,
+ )
diff --git a/homeassistant/components/bayesian/const.py b/homeassistant/components/bayesian/const.py
index cac4237b4ec..239c6cfa5c4 100644
--- a/homeassistant/components/bayesian/const.py
+++ b/homeassistant/components/bayesian/const.py
@@ -1,5 +1,9 @@
"""Consts for using in modules."""
+from homeassistant.const import Platform
+
+DOMAIN = "bayesian"
+PLATFORMS = [Platform.BINARY_SENSOR]
ATTR_OBSERVATIONS = "observations"
ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities"
ATTR_PROBABILITY = "probability"
diff --git a/homeassistant/components/bayesian/issues.py b/homeassistant/components/bayesian/issues.py
index b35c788053d..35080949c6f 100644
--- a/homeassistant/components/bayesian/issues.py
+++ b/homeassistant/components/bayesian/issues.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
-from . import DOMAIN
+from .const import DOMAIN
from .helpers import Observation
diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json
index df1ab9c7609..def56cb8898 100644
--- a/homeassistant/components/bayesian/manifest.json
+++ b/homeassistant/components/bayesian/manifest.json
@@ -2,8 +2,9 @@
"domain": "bayesian",
"name": "Bayesian",
"codeowners": ["@HarvsG"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bayesian",
- "integration_type": "helper",
- "iot_class": "local_polling",
+ "integration_type": "service",
+ "iot_class": "calculated",
"quality_scale": "internal"
}
diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json
index 00de79a2229..7204c867623 100644
--- a/homeassistant/components/bayesian/strings.json
+++ b/homeassistant/components/bayesian/strings.json
@@ -14,5 +14,264 @@
"name": "[%key:common::action::reload%]",
"description": "Reloads Bayesian sensors from the YAML-configuration."
}
+ },
+ "options": {
+ "error": {
+ "extreme_prior_error": "[%key:component::bayesian::config::error::extreme_prior_error%]",
+ "extreme_threshold_error": "[%key:component::bayesian::config::error::extreme_threshold_error%]",
+ "equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]",
+ "extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]"
+ },
+ "step": {
+ "init": {
+ "title": "Sensor options",
+ "description": "These options affect how much evidence is required for the Bayesian sensor to be considered 'on'.",
+ "data": {
+ "probability_threshold": "[%key:component::bayesian::config::step::user::data::probability_threshold%]",
+ "prior": "[%key:component::bayesian::config::step::user::data::prior%]",
+ "device_class": "[%key:component::bayesian::config::step::user::data::device_class%]",
+ "name": "[%key:common::config_flow::data::name%]"
+ },
+ "data_description": {
+ "probability_threshold": "[%key:component::bayesian::config::step::user::data_description::probability_threshold%]",
+ "prior": "[%key:component::bayesian::config::step::user::data_description::prior%]"
+ }
+ }
+ }
+ },
+ "config": {
+ "error": {
+ "extreme_prior_error": "'Prior' set to 0% means that it is impossible for the sensor to show 'on' and 100% means it will never show 'off', use a close number like 0.1% or 99.9% instead",
+ "extreme_threshold_error": "'Probability threshold' set to 0% means that the sensor will always be 'on' and 100% mean it will always be 'off', use a close number like 0.1% or 99.9% instead",
+ "equal_probabilities": "If 'Probability given true' and 'Probability given false' are equal, this observation can have no effect, and is therefore redundant",
+ "extreme_prob_given_error": "If either 'Probability given false' or 'Probability given true' is 0 or 100 this will create certainties that override all other observations, use numbers close to 0 or 100 instead",
+ "above_below": "Invalid range: 'Above' must be less than 'Below' when both are set.",
+ "above_or_below": "Invalid range: At least one of 'Above' or 'Below' must be set.",
+ "overlapping_ranges": "Invalid range: The 'Above' and 'Below' values overlap with another observation for the same entity."
+ },
+ "step": {
+ "user": {
+ "title": "Add a Bayesian sensor",
+ "description": "Create a binary sensor which observes the state of multiple sensors to estimate whether an event is occurring, or if something is true. See [the documentation]({url}) for more details.",
+ "data": {
+ "probability_threshold": "Probability threshold",
+ "prior": "Prior",
+ "device_class": "Device class",
+ "name": "[%key:common::config_flow::data::name%]"
+ },
+ "data_description": {
+ "probability_threshold": "The probability above which the sensor will show as 'on'. 50% should produce the most accurate result. Use numbers greater than 50% if avoiding false positives is important, or vice-versa.",
+ "prior": "The baseline probability the sensor should be 'on', this is usually the percentage of time it is true. For example, for a sensor 'Everyone sleeping' it might be 8 hours a day, 33%.",
+ "device_class": "Choose the device class you would like the sensor to show as."
+ }
+ },
+ "observation_selector": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::user::title%]",
+ "description": "[%key:component::bayesian::config_subentries::observation::step::user::description%]",
+ "menu_options": {
+ "state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::state%]",
+ "numeric_state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::numeric_state%]",
+ "template": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::template%]",
+ "finish": "Finish"
+ }
+ },
+ "state": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
+ "description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]",
+
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
+ "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
+ "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ },
+ "numeric_state": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
+ "description": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::description%]",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
+ "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]",
+ "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
+ "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]",
+ "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ },
+ "template": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
+ "description": "[%key:component::bayesian::config_subentries::observation::step::template::description%]",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ }
+ }
+ },
+ "config_subentries": {
+ "observation": {
+ "step": {
+ "user": {
+ "title": "Add an observation",
+ "description": "'Observations' are the sensor or template values that are monitored and then combined in order to inform the Bayesian sensor's final probability. Each observation will update the probability of the Bayesian sensor if it is detected, or if it is not detected. If the state of the entity becomes `unavailable` or `unknown` it will be ignored. If more than one state or more than one numeric range is configured for the same entity then inverse detections will be ignored.",
+ "menu_options": {
+ "state": "Add an observation for a sensor's state",
+ "numeric_state": "Add an observation for a numeric range",
+ "template": "Add an observation for a template"
+ }
+ },
+ "state": {
+ "title": "Add a Bayesian sensor",
+ "description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
+
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "Entity",
+ "to_state": "To state",
+ "prob_given_true": "Probability when {parent_sensor_name} is {device_class_on}",
+ "prob_given_false": "Probability when {parent_sensor_name} is {device_class_off}"
+ },
+ "data_description": {
+ "name": "This name will be used for to identify this observation for editing in the future.",
+ "entity_id": "An entity that is correlated with `{parent_sensor_name}`.",
+ "to_state": "The state of the sensor for which the observation will be considered `True`.",
+ "prob_given_true": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_on}`.",
+ "prob_given_false": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_off}`."
+ }
+ },
+ "numeric_state": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
+ "description": "Add an observation which evaluates to `True` when a numeric sensor is within a chosen range.",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
+ "above": "Above",
+ "below": "Below",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
+ "above": "Optional - the lower end of the numeric range. Values exactly matching this will not count",
+ "below": "Optional - the upper end of the numeric range. Values exactly matching this will only count if more than one range is configured for the same entity.",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ },
+ "template": {
+ "title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
+ "description": "Add a custom observation which evaluates whether a template is observed (`True`) or not (`False`).",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "value_template": "Template",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "value_template": "A template that evaluates to `True` will update the prior accordingly, A template that returns `False` or `None` will update the prior with inverse probabilities. A template that returns an error will not update probabilities. Results are coerced into being `True` or `False`",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ },
+ "reconfigure": {
+ "title": "Edit observation",
+ "description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
+ "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]",
+ "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]",
+ "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]",
+ "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
+ },
+ "data_description": {
+ "name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
+ "entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
+ "to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]",
+ "above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]",
+ "below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]",
+ "value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]",
+ "prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
+ "prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
+ }
+ }
+ },
+ "initiate_flow": {
+ "user": "[%key:component::bayesian::config_subentries::observation::step::user::title%]"
+ },
+ "entry_type": "Observation",
+ "error": {
+ "equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]",
+ "extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]",
+ "above_below": "[%key:component::bayesian::config::error::above_below%]",
+ "above_or_below": "[%key:component::bayesian::config::error::above_or_below%]",
+ "overlapping_ranges": "[%key:component::bayesian::config::error::overlapping_ranges%]"
+ },
+ "abort": {
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ }
+ }
+ },
+ "selector": {
+ "binary_sensor_device_class": {
+ "options": {
+ "battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
+ "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]",
+ "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]",
+ "cold": "[%key:component::binary_sensor::entity_component::cold::name%]",
+ "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]",
+ "door": "[%key:component::binary_sensor::entity_component::door::name%]",
+ "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]",
+ "gas": "[%key:component::binary_sensor::entity_component::gas::name%]",
+ "heat": "[%key:component::binary_sensor::entity_component::heat::name%]",
+ "light": "[%key:component::binary_sensor::entity_component::light::name%]",
+ "lock": "[%key:component::binary_sensor::entity_component::lock::name%]",
+ "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]",
+ "motion": "[%key:component::binary_sensor::entity_component::motion::name%]",
+ "moving": "[%key:component::binary_sensor::entity_component::moving::name%]",
+ "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]",
+ "opening": "[%key:component::binary_sensor::entity_component::opening::name%]",
+ "plug": "[%key:component::binary_sensor::entity_component::plug::name%]",
+ "power": "[%key:component::binary_sensor::entity_component::power::name%]",
+ "presence": "[%key:component::binary_sensor::entity_component::presence::name%]",
+ "problem": "[%key:component::binary_sensor::entity_component::problem::name%]",
+ "running": "[%key:component::binary_sensor::entity_component::running::name%]",
+ "safety": "[%key:component::binary_sensor::entity_component::safety::name%]",
+ "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]",
+ "sound": "[%key:component::binary_sensor::entity_component::sound::name%]",
+ "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]",
+ "update": "[%key:component::binary_sensor::entity_component::update::name%]",
+ "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]",
+ "window": "[%key:component::binary_sensor::entity_component::window::name%]"
+ }
+ }
}
}
diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py
index eeda91a70a3..5d066968873 100644
--- a/homeassistant/components/blue_current/__init__.py
+++ b/homeassistant/components/blue_current/__init__.py
@@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import (
RequestLimitReached,
WebsocketError,
)
+import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_API_TOKEN, Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryNotReady,
+ ServiceValidationError,
+)
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.typing import ConfigType
from .const import (
+ BCU_APP,
CHARGEPOINT_SETTINGS,
CHARGEPOINT_STATUS,
+ CHARGING_CARD_ID,
DOMAIN,
EVSE_ID,
LOGGER,
PLUG_AND_CHARGE,
+ SERVICE_START_CHARGE_SESSION,
VALUE,
)
@@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
CHARGE_POINTS = "CHARGE_POINTS"
+CHARGE_CARDS = "CHARGE_CARDS"
DATA = "data"
DELAY = 5
@@ -41,6 +52,16 @@ GRID = "GRID"
OBJECT = "object"
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ # When no charging card is provided, use no charging card (BCU_APP = no charging card).
+ vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
+ }
+)
+
async def async_setup_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
@@ -67,6 +88,66 @@ async def async_setup_entry(
return True
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Blue Current."""
+
+ async def start_charge_session(service_call: ServiceCall) -> None:
+ """Start a charge session with the provided device and charge card ID."""
+ # When no charge card is provided, use the default charge card set in the config flow.
+ charging_card_id = service_call.data[CHARGING_CARD_ID]
+ device_id = service_call.data[CONF_DEVICE_ID]
+
+ # Get the device based on the given device ID.
+ device = dr.async_get(hass).devices.get(device_id)
+
+ if device is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="invalid_device_id"
+ )
+
+ blue_current_config_entry: ConfigEntry | None = None
+
+ for config_entry_id in device.config_entries:
+ config_entry = hass.config_entries.async_get_entry(config_entry_id)
+ if not config_entry or config_entry.domain != DOMAIN:
+ # Not the blue_current config entry.
+ continue
+
+ if config_entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
+ )
+
+ blue_current_config_entry = config_entry
+ break
+
+ if not blue_current_config_entry:
+ # The device is not connected to a valid blue_current config entry.
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="no_config_entry"
+ )
+
+ connector = blue_current_config_entry.runtime_data
+
+ # Get the evse_id from the identifier of the device.
+ evse_id = next(
+ identifier[1]
+ for identifier in device.identifiers
+ if identifier[0] == DOMAIN
+ )
+
+ await connector.client.start_session(evse_id, charging_card_id)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ start_charge_session,
+ SERVICE_START_CHARGE_SESSION_SCHEMA,
+ )
+
+ return True
+
+
async def async_unload_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
) -> bool:
@@ -87,6 +168,7 @@ class Connector:
self.client = client
self.charge_points: dict[str, dict] = {}
self.grid: dict[str, Any] = {}
+ self.charge_cards: dict[str, dict[str, Any]] = {}
async def on_data(self, message: dict) -> None:
"""Handle received data."""
diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py
index 33e0e8b1176..16b737730b9 100644
--- a/homeassistant/components/blue_current/const.py
+++ b/homeassistant/components/blue_current/const.py
@@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__)
EVSE_ID = "evse_id"
MODEL_TYPE = "model_type"
+CARD = "card"
+UID = "uid"
+BCU_APP = "BCU-APP"
+WITHOUT_CHARGING_CARD = "without_charging_card"
+CHARGING_CARD_ID = "charging_card_id"
+SERVICE_START_CHARGE_SESSION = "start_charge_session"
PLUG_AND_CHARGE = "plug_and_charge"
VALUE = "value"
PERMISSION = "permission"
diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json
index 28d4acbc1d8..b8c6a5f045b 100644
--- a/homeassistant/components/blue_current/icons.json
+++ b/homeassistant/components/blue_current/icons.json
@@ -42,5 +42,10 @@
"default": "mdi:lock"
}
}
+ },
+ "services": {
+ "start_charge_session": {
+ "service": "mdi:play"
+ }
}
}
diff --git a/homeassistant/components/blue_current/services.yaml b/homeassistant/components/blue_current/services.yaml
new file mode 100644
index 00000000000..70992b5f277
--- /dev/null
+++ b/homeassistant/components/blue_current/services.yaml
@@ -0,0 +1,12 @@
+start_charge_session:
+ fields:
+ device_id:
+ selector:
+ device:
+ integration: blue_current
+ required: true
+
+ charging_card_id:
+ selector:
+ text:
+ required: false
diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json
index 0a99af603cc..9fdbd756392 100644
--- a/homeassistant/components/blue_current/strings.json
+++ b/homeassistant/components/blue_current/strings.json
@@ -22,6 +22,16 @@
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
}
},
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "card": "Card"
+ },
+ "description": "Select the default charging card you want to use"
+ }
+ }
+ },
"entity": {
"sensor": {
"activity": {
@@ -136,5 +146,39 @@
"name": "Block charge point"
}
}
+ },
+ "selector": {
+ "select_charging_card": {
+ "options": {
+ "without_charging_card": "Without charging card"
+ }
+ }
+ },
+ "services": {
+ "start_charge_session": {
+ "name": "Start charge session",
+ "description": "Starts a new charge session on a specified charge point.",
+ "fields": {
+ "charging_card_id": {
+ "name": "Charging card ID",
+ "description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
+ },
+ "device_id": {
+ "name": "Device ID",
+ "description": "The ID of the Blue Current charge point."
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "invalid_device_id": {
+ "message": "Invalid device ID given."
+ },
+ "config_entry_not_loaded": {
+ "message": "Config entry not loaded."
+ },
+ "no_config_entry": {
+ "message": "Device has not a valid blue_current config entry."
+ }
}
}
diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json
index 54fb061676d..f4e49e00175 100644
--- a/homeassistant/components/bluesound/manifest.json
+++ b/homeassistant/components/bluesound/manifest.json
@@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
- "requirements": ["pyblu==2.0.4"],
+ "requirements": ["pyblu==2.0.5"],
"zeroconf": [
{
"type": "_musc._tcp.local."
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 2662562f575..115c6d054af 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -321,8 +321,14 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self.available is False or (self.is_grouped and not self.is_leader):
return None
- sources = [x.text for x in self._inputs]
- sources += [x.name for x in self._presets]
+ sources = [x.name for x in self._presets]
+
+ # ignore if both id and text are None
+ for input_ in self._inputs:
+ if input_.text is not None:
+ sources.append(input_.text)
+ elif input_.id is not None:
+ sources.append(input_.id)
return sources
@@ -340,7 +346,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
input_.id == self._status.input_id
or input_.url == self._status.stream_url
):
- return input_.text
+ return input_.text if input_.text is not None else input_.id
for preset in self._presets:
if preset.url == self._status.stream_url:
@@ -537,7 +543,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
# presets and inputs might have the same name; presets have priority
for input_ in self._inputs:
- if input_.text == source:
+ if source in (input_.text, input_.id):
await self._player.play_url(input_.url)
return
for preset in self._presets:
diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py
index e3428eb9b86..3559adfd976 100644
--- a/homeassistant/components/bluetooth/__init__.py
+++ b/homeassistant/components/bluetooth/__init__.py
@@ -57,6 +57,7 @@ from .api import (
_get_manager,
async_address_present,
async_ble_device_from_address,
+ async_current_scanners,
async_discovered_service_info,
async_get_advertisement_callback,
async_get_fallback_availability_interval,
@@ -114,6 +115,7 @@ __all__ = [
"HomeAssistantRemoteScanner",
"async_address_present",
"async_ble_device_from_address",
+ "async_current_scanners",
"async_discovered_service_info",
"async_get_advertisement_callback",
"async_get_fallback_availability_interval",
@@ -385,10 +387,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Bluetooth adapter {adapter} with address {address} not found"
)
passive = entry.options.get(CONF_PASSIVE)
+ adapters = await manager.async_get_bluetooth_adapters()
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
scanner = HaScanner(mode, adapter, address)
scanner.async_setup()
- adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter]
if entry.title == address:
hass.config_entries.async_update_entry(
diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py
index 00e585fa266..556ae2ac9fd 100644
--- a/homeassistant/components/bluetooth/api.py
+++ b/homeassistant/components/bluetooth/api.py
@@ -10,6 +10,7 @@ from asyncio import Future
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast
+from bleak import BleakScanner
from habluetooth import (
BaseHaScanner,
BluetoothScannerDevice,
@@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
@hass_callback
-def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
- """Return a HaBleakScannerWrapper.
+def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
+ """Return a HaBleakScannerWrapper cast to BleakScanner.
This is a wrapper around our BleakScanner singleton that allows
multiple integrations to share the same BleakScanner.
+
+ The wrapper is cast to BleakScanner for type compatibility with
+ libraries expecting a BleakScanner instance.
"""
- return HaBleakScannerWrapper()
+ return cast(BleakScanner, HaBleakScannerWrapper())
@hass_callback
@@ -66,6 +70,22 @@ def async_scanner_count(hass: HomeAssistant, connectable: bool = True) -> int:
return _get_manager(hass).async_scanner_count(connectable)
+@hass_callback
+def async_current_scanners(hass: HomeAssistant) -> list[BaseHaScanner]:
+ """Return the list of currently active scanners.
+
+ This method returns a list of all active Bluetooth scanners registered
+ with Home Assistant, including both connectable and non-connectable scanners.
+
+ Args:
+ hass: Home Assistant instance
+
+ Returns:
+ List of all active scanner instances
+ """
+ return _get_manager(hass).async_current_scanners()
+
+
@hass_callback
def async_discovered_service_info(
hass: HomeAssistant, connectable: bool = True
diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py
index 5f3cb62c158..c43f7dd5fd7 100644
--- a/homeassistant/components/bluetooth/manager.py
+++ b/homeassistant/components/bluetooth/manager.py
@@ -8,8 +8,19 @@ import itertools
import logging
from bleak_retry_connector import BleakSlotManager
-from bluetooth_adapters import BluetoothAdapters
-from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager
+from bluetooth_adapters import (
+ ADAPTER_TYPE,
+ BluetoothAdapters,
+ adapter_human_name,
+ adapter_model,
+)
+from habluetooth import (
+ BaseHaRemoteScanner,
+ BaseHaScanner,
+ BluetoothManager,
+ BluetoothScanningMode,
+ HaScanner,
+)
from homeassistant import config_entries
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
@@ -19,8 +30,9 @@ from homeassistant.core import (
HomeAssistant,
callback as hass_callback,
)
-from homeassistant.helpers import discovery_flow
+from homeassistant.helpers import discovery_flow, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.package import is_docker_env
from .const import (
CONF_SOURCE,
@@ -314,3 +326,97 @@ class HomeAssistantBluetoothManager(BluetoothManager):
address = discovery_key.key
_LOGGER.debug("Rediscover address %s", address)
self.async_rediscover_address(address)
+
+ def on_scanner_start(self, scanner: BaseHaScanner) -> None:
+ """Handle when a scanner starts.
+
+ Create or delete repair issues for local adapters based on degraded mode.
+ """
+ super().on_scanner_start(scanner)
+
+ # Only handle repair issues for local adapters (HaScanner instances)
+ if not isinstance(scanner, HaScanner):
+ return
+ self.async_check_degraded_mode(scanner)
+ self.async_check_scanning_mode(scanner)
+
+ @hass_callback
+ def async_check_scanning_mode(self, scanner: HaScanner) -> None:
+ """Check if the scanner is running in passive mode when active mode is requested."""
+ passive_mode_issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
+
+ # Check if scanner is NOT in passive mode when active mode was requested
+ if not (
+ scanner.requested_mode is BluetoothScanningMode.ACTIVE
+ and scanner.current_mode is BluetoothScanningMode.PASSIVE
+ ):
+ # Delete passive mode issue if it exists and we're not in passive fallback
+ ir.async_delete_issue(self.hass, DOMAIN, passive_mode_issue_id)
+ return
+
+ # Create repair issue for passive mode fallback
+ adapter_name = adapter_human_name(
+ scanner.adapter, scanner.mac_address or "00:00:00:00:00:00"
+ )
+ adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter)
+ model = adapter_model(adapter_details) if adapter_details else None
+
+ # Determine adapter type for specific instructions
+ # Default to USB for any other type or unknown
+ if adapter_details and adapter_details.get(ADAPTER_TYPE) == "uart":
+ translation_key = "bluetooth_adapter_passive_mode_uart"
+ else:
+ translation_key = "bluetooth_adapter_passive_mode_usb"
+
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ passive_mode_issue_id,
+ is_fixable=False, # Requires a reboot or unplug
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=translation_key,
+ translation_placeholders={
+ "adapter": adapter_name,
+ "model": model or "Unknown",
+ },
+ )
+
+ @hass_callback
+ def async_check_degraded_mode(self, scanner: HaScanner) -> None:
+ """Check if we are in degraded mode and create/delete repair issues."""
+ issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"
+
+ # Delete any existing issue if not in degraded mode
+ if not self.is_operating_degraded():
+ ir.async_delete_issue(self.hass, DOMAIN, issue_id)
+ return
+
+ # Only create repair issues for Docker-based installations where users
+ # can fix permissions. This includes: Home Assistant Supervised,
+ # Home Assistant Container, and third-party containers
+ if not is_docker_env():
+ return
+
+ # Create repair issue for degraded mode in Docker (including Supervised)
+ adapter_name = adapter_human_name(
+ scanner.adapter, scanner.mac_address or "00:00:00:00:00:00"
+ )
+
+ # Try to get adapter details from the bluetooth adapters
+ adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter)
+ model = adapter_model(adapter_details) if adapter_details else None
+
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False, # Not fixable from within HA - requires
+ # container restart with new permissions
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="bluetooth_adapter_missing_permissions",
+ translation_placeholders={
+ "adapter": adapter_name,
+ "model": model or "Unknown",
+ "docs_url": "https://www.home-assistant.io/integrations/bluetooth/#additional-details-for-container",
+ },
+ )
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index 9efbd321123..77ed782a5ad 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==1.0.1",
- "bleak-retry-connector==4.3.0",
- "bluetooth-adapters==2.0.0",
- "bluetooth-auto-recovery==1.5.2",
- "bluetooth-data-tools==1.28.2",
- "dbus-fast==2.44.3",
- "habluetooth==5.1.0"
+ "bleak-retry-connector==4.4.3",
+ "bluetooth-adapters==2.1.0",
+ "bluetooth-auto-recovery==1.5.3",
+ "bluetooth-data-tools==1.28.3",
+ "dbus-fast==2.44.5",
+ "habluetooth==5.7.0"
]
}
diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json
index 866b76c0985..5cbc3992f16 100644
--- a/homeassistant/components/bluetooth/strings.json
+++ b/homeassistant/components/bluetooth/strings.json
@@ -38,5 +38,19 @@
"remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported.",
"local_adapters_no_passive_support": "Local Bluetooth adapters that do not support passive scanning cannot be configured."
}
+ },
+ "issues": {
+ "bluetooth_adapter_missing_permissions": {
+ "title": "Bluetooth adapter requires additional permissions",
+ "description": "The Bluetooth adapter **{adapter}** ({model}) is operating in degraded mode because your container needs additional permissions to fully access Bluetooth hardware.\n\nPlease follow the instructions in our documentation to add the required permissions:\n[Bluetooth permissions for Docker]({docs_url})\n\nAfter adding these permissions, restart your Home Assistant container for the changes to take effect."
+ },
+ "bluetooth_adapter_passive_mode_usb": {
+ "title": "Bluetooth USB adapter requires manual power cycle",
+ "description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the adapter requires a manual power cycle to recover.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Unplug the USB adapter**\n2. Wait 5 seconds\n3. **Plug it back in**\n4. Wait for Home Assistant to detect the adapter\n\nIf the issue persists after power cycling:\n- Try a different USB port\n- Check for kernel/firmware updates\n- Consider using a different Bluetooth adapter"
+ },
+ "bluetooth_adapter_passive_mode_uart": {
+ "title": "Bluetooth adapter requires system power cycle",
+ "description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the system requires a complete power cycle to recover the adapter.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Shut down the system completely** (not just a reboot)\n2. **Remove power** (unplug or turn off at the switch)\n3. Wait 10 seconds\n4. Restore power and boot the system\n\nIf the issue persists after power cycling:\n- Check for kernel/firmware updates\n- The onboard Bluetooth adapter may have hardware issues"
+ }
}
}
diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py
index 9022d98bf06..042fe3fe24b 100644
--- a/homeassistant/components/bluetooth/websocket_api.py
+++ b/homeassistant/components/bluetooth/websocket_api.py
@@ -8,8 +8,10 @@ import time
from typing import Any
from habluetooth import (
+ BaseHaScanner,
BluetoothScanningMode,
HaBluetoothSlotAllocations,
+ HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
@@ -27,12 +29,54 @@ from .models import BluetoothChange
from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source
+@callback
+def _async_get_source_from_config_entry(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg_id: int,
+ config_entry_id: str | None,
+ validate_source: bool = True,
+) -> str | None:
+ """Get source from config entry id.
+
+ Returns None if no config_entry_id provided or on error (after sending error response).
+ If validate_source is True, also validates that the scanner exists.
+ """
+ if not config_entry_id:
+ return None
+
+ if validate_source:
+ # Use the full validation that checks if scanner exists
+ try:
+ return config_entry_id_to_source(hass, config_entry_id)
+ except InvalidConfigEntryID as err:
+ connection.send_error(msg_id, "invalid_config_entry_id", str(err))
+ return None
+ except InvalidSource as err:
+ connection.send_error(msg_id, "invalid_source", str(err))
+ return None
+
+ # Just check if config entry exists and belongs to bluetooth
+ if (
+ not (entry := hass.config_entries.async_get_entry(config_entry_id))
+ or entry.domain != DOMAIN
+ ):
+ connection.send_error(
+ msg_id,
+ "invalid_config_entry_id",
+ f"Config entry {config_entry_id} not found",
+ )
+ return None
+ return entry.unique_id
+
+
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the bluetooth websocket API."""
websocket_api.async_register_command(hass, ws_subscribe_advertisements)
websocket_api.async_register_command(hass, ws_subscribe_connection_allocations)
websocket_api.async_register_command(hass, ws_subscribe_scanner_details)
+ websocket_api.async_register_command(hass, ws_subscribe_scanner_state)
@lru_cache(maxsize=1024)
@@ -180,16 +224,12 @@ async def ws_subscribe_connection_allocations(
) -> None:
"""Handle subscribe advertisements websocket command."""
ws_msg_id = msg["id"]
- source: str | None = None
- if config_entry_id := msg.get("config_entry_id"):
- try:
- source = config_entry_id_to_source(hass, config_entry_id)
- except InvalidConfigEntryID as err:
- connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err))
- return
- except InvalidSource as err:
- connection.send_error(ws_msg_id, "invalid_source", str(err))
- return
+ config_entry_id = msg.get("config_entry_id")
+ source = _async_get_source_from_config_entry(
+ hass, connection, ws_msg_id, config_entry_id
+ )
+ if config_entry_id and source is None:
+ return # Error already sent by helper
def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None:
connection.send_message(
@@ -220,20 +260,12 @@ async def ws_subscribe_scanner_details(
) -> None:
"""Handle subscribe scanner details websocket command."""
ws_msg_id = msg["id"]
- source: str | None = None
- if config_entry_id := msg.get("config_entry_id"):
- if (
- not (entry := hass.config_entries.async_get_entry(config_entry_id))
- or entry.domain != DOMAIN
- ):
- connection.send_error(
- ws_msg_id,
- "invalid_config_entry_id",
- f"Invalid config entry id: {config_entry_id}",
- )
- return
- source = entry.unique_id
- assert source is not None
+ config_entry_id = msg.get("config_entry_id")
+ source = _async_get_source_from_config_entry(
+ hass, connection, ws_msg_id, config_entry_id, validate_source=False
+ )
+ if config_entry_id and source is None:
+ return # Error already sent by helper
def _async_event_message(message: dict[str, Any]) -> None:
connection.send_message(
@@ -260,3 +292,70 @@ async def ws_subscribe_scanner_details(
]
):
_async_event_message({"add": matching_scanners})
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "bluetooth/subscribe_scanner_state",
+ vol.Optional("config_entry_id"): str,
+ }
+)
+@websocket_api.async_response
+async def ws_subscribe_scanner_state(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Handle subscribe scanner state websocket command."""
+ ws_msg_id = msg["id"]
+ config_entry_id = msg.get("config_entry_id")
+ source = _async_get_source_from_config_entry(
+ hass, connection, ws_msg_id, config_entry_id, validate_source=False
+ )
+ if config_entry_id and source is None:
+ return # Error already sent by helper
+
+ @callback
+ def _async_send_scanner_state(
+ scanner: BaseHaScanner,
+ current_mode: BluetoothScanningMode | None,
+ requested_mode: BluetoothScanningMode | None,
+ ) -> None:
+ payload = {
+ "source": scanner.source,
+ "adapter": scanner.adapter,
+ "current_mode": current_mode.value if current_mode else None,
+ "requested_mode": requested_mode.value if requested_mode else None,
+ }
+ connection.send_message(
+ json_bytes(
+ websocket_api.event_message(
+ ws_msg_id,
+ payload,
+ )
+ )
+ )
+
+ @callback
+ def _async_scanner_state_changed(mode_change: HaScannerModeChange) -> None:
+ _async_send_scanner_state(
+ mode_change.scanner,
+ mode_change.current_mode,
+ mode_change.requested_mode,
+ )
+
+ manager = _get_manager(hass)
+ connection.subscriptions[ws_msg_id] = (
+ manager.async_register_scanner_mode_change_callback(
+ _async_scanner_state_changed, source
+ )
+ )
+ connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id)))
+
+ # Send initial state for all matching scanners
+ for scanner in manager.async_current_scanners():
+ if source is None or scanner.source == source:
+ _async_send_scanner_state(
+ scanner,
+ scanner.current_mode,
+ scanner.requested_mode,
+ )
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index 81928a59a52..327b47bbea2 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
- "requirements": ["bimmer-connected[china]==0.17.2"]
+ "requirements": ["bimmer-connected[china]==0.17.3"]
}
diff --git a/homeassistant/components/braviatv/diagnostics.py b/homeassistant/components/braviatv/diagnostics.py
index b858fd41c09..13019dacd96 100644
--- a/homeassistant/components/braviatv/diagnostics.py
+++ b/homeassistant/components/braviatv/diagnostics.py
@@ -18,8 +18,10 @@ async def async_get_config_entry_diagnostics(
coordinator = config_entry.runtime_data
device_info = await coordinator.client.get_system_info()
+ command_list = await coordinator.client.get_command_list()
return {
+ "remote_command_list": command_list,
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"device_info": async_redact_data(device_info, TO_REDACT),
}
diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py
index e1c6260b070..faeaed7a5d1 100644
--- a/homeassistant/components/braviatv/entity.py
+++ b/homeassistant/components/braviatv/entity.py
@@ -1,5 +1,7 @@
"""A entity class for Bravia TV integration."""
+from typing import TYPE_CHECKING
+
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -17,11 +19,15 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]):
super().__init__(coordinator)
self._attr_unique_id = unique_id
+
+ if TYPE_CHECKING:
+ assert coordinator.client.mac is not None
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
- connections={(CONNECTION_NETWORK_MAC, coordinator.system_info["macAddr"])},
+ connections={(CONNECTION_NETWORK_MAC, coordinator.client.mac)},
manufacturer=ATTR_MANUFACTURER,
- model_id=coordinator.system_info["model"],
- hw_version=coordinator.system_info["generation"],
- serial_number=coordinator.system_info["serial"],
+ model_id=coordinator.system_info.get("model"),
+ hw_version=coordinator.system_info.get("generation"),
+ serial_number=coordinator.system_info.get("serial"),
)
diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py
index 0a8d980a6aa..e03acca5bb5 100644
--- a/homeassistant/components/bring/coordinator.py
+++ b/homeassistant/components/bring/coordinator.py
@@ -205,6 +205,7 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]
async def _async_update_data(self) -> dict[str, BringActivityData]:
"""Fetch activity data from bring."""
+ self.lists = self.coordinator.lists
list_dict: dict[str, BringActivityData] = {}
for lst in self.lists:
diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py
index e9e286dccf0..9cc41af10f7 100644
--- a/homeassistant/components/bring/event.py
+++ b/homeassistant/components/bring/event.py
@@ -43,7 +43,7 @@ async def async_setup_entry(
)
lists_added |= new_lists
- coordinator.activity.async_add_listener(add_entities)
+ coordinator.data.async_add_listener(add_entities)
add_entities()
@@ -67,7 +67,8 @@ class BringEventEntity(BringBaseEntity, EventEntity):
def _async_handle_event(self) -> None:
"""Handle the activity event."""
- bring_list = self.coordinator.data[self._list_uuid]
+ if (bring_list := self.coordinator.data.get(self._list_uuid)) is None:
+ return
last_event_triggered = self.state
if bring_list.activity.timeline and (
last_event_triggered is None
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index 48677d52523..6ce16ca52ca 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -164,10 +164,6 @@
"name": "[%key:component::notify::services::notify::name%]",
"description": "Sends a mobile push notification to members of a shared Bring! list.",
"fields": {
- "entity_id": {
- "name": "List",
- "description": "Bring! list whose members (except sender) will be notified."
- },
"message": {
"name": "Notification type",
"description": "Type of push notification to send to list members."
diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py
index 1c1768b58fd..e732438bc03 100644
--- a/homeassistant/components/brother/__init__.py
+++ b/homeassistant/components/brother/__init__.py
@@ -2,28 +2,40 @@
from __future__ import annotations
+import logging
+
from brother import Brother, SnmpError
from homeassistant.components.snmp import async_get_snmp_engine
-from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN
+from .const import (
+ CONF_COMMUNITY,
+ DEFAULT_COMMUNITY,
+ DEFAULT_PORT,
+ DOMAIN,
+ SECTION_ADVANCED_SETTINGS,
+)
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
+_LOGGER = logging.getLogger(__name__)
+
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Set up Brother from a config entry."""
host = entry.data[CONF_HOST]
+ port = entry.data[SECTION_ADVANCED_SETTINGS][CONF_PORT]
+ community = entry.data[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY]
printer_type = entry.data[CONF_TYPE]
snmp_engine = await async_get_snmp_engine(hass)
try:
brother = await Brother.create(
- host, printer_type=printer_type, snmp_engine=snmp_engine
+ host, port, community, printer_type=printer_type, snmp_engine=snmp_engine
)
except (ConnectionError, SnmpError, TimeoutError) as error:
raise ConfigEntryNotReady(
@@ -48,3 +60,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
+ """Migrate an old entry."""
+ if entry.version == 1 and entry.minor_version < 2:
+ new_data = entry.data.copy()
+ new_data[SECTION_ADVANCED_SETTINGS] = {
+ CONF_PORT: DEFAULT_PORT,
+ CONF_COMMUNITY: DEFAULT_COMMUNITY,
+ }
+ hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
+
+ _LOGGER.info(
+ "Migration to configuration version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py
index f6b3f456056..e4167dbf752 100644
--- a/homeassistant/components/brother/config_flow.py
+++ b/homeassistant/components/brother/config_flow.py
@@ -9,21 +9,65 @@ import voluptuous as vol
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_TYPE
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_host_valid
-from .const import DOMAIN, PRINTER_TYPES
+from .const import (
+ CONF_COMMUNITY,
+ DEFAULT_COMMUNITY,
+ DEFAULT_PORT,
+ DOMAIN,
+ PRINTER_TYPES,
+ SECTION_ADVANCED_SETTINGS,
+)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES),
+ vol.Required(SECTION_ADVANCED_SETTINGS): section(
+ vol.Schema(
+ {
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
+ },
+ ),
+ {"collapsed": True},
+ ),
+ }
+)
+ZEROCONF_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES),
+ vol.Required(SECTION_ADVANCED_SETTINGS): section(
+ vol.Schema(
+ {
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
+ },
+ ),
+ {"collapsed": True},
+ ),
+ }
+)
+RECONFIGURE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(SECTION_ADVANCED_SETTINGS): section(
+ vol.Schema(
+ {
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
+ },
+ ),
+ {"collapsed": True},
+ ),
}
)
-RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
async def validate_input(
@@ -35,7 +79,12 @@ async def validate_input(
snmp_engine = await async_get_snmp_engine(hass)
- brother = await Brother.create(user_input[CONF_HOST], snmp_engine=snmp_engine)
+ brother = await Brother.create(
+ user_input[CONF_HOST],
+ user_input[SECTION_ADVANCED_SETTINGS][CONF_PORT],
+ user_input[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY],
+ snmp_engine=snmp_engine,
+ )
await brother.async_update()
if expected_mac is not None and brother.serial.lower() != expected_mac:
@@ -48,6 +97,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Brother Printer."""
VERSION = 1
+ MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize."""
@@ -126,13 +176,11 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
title = f"{self.brother.model} {self.brother.serial}"
return self.async_create_entry(
title=title,
- data={CONF_HOST: self.host, CONF_TYPE: user_input[CONF_TYPE]},
+ data={CONF_HOST: self.host, **user_input},
)
return self.async_show_form(
step_id="zeroconf_confirm",
- data_schema=vol.Schema(
- {vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES)}
- ),
+ data_schema=ZEROCONF_SCHEMA,
description_placeholders={
"serial_number": self.brother.serial,
"model": self.brother.model,
@@ -160,7 +208,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_update_reload_and_abort(
entry,
- data_updates={CONF_HOST: user_input[CONF_HOST]},
+ data_updates=user_input,
)
return self.async_show_form(
diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py
index c0ae7cf60b0..85b8a2a4a55 100644
--- a/homeassistant/components/brother/const.py
+++ b/homeassistant/components/brother/const.py
@@ -10,3 +10,10 @@ DOMAIN: Final = "brother"
PRINTER_TYPES: Final = ["laser", "ink"]
UPDATE_INTERVAL = timedelta(seconds=30)
+
+SECTION_ADVANCED_SETTINGS = "advanced_settings"
+
+CONF_COMMUNITY = "community"
+
+DEFAULT_COMMUNITY = "public"
+DEFAULT_PORT = 161
diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json
index 356ba4f01fc..3fdb1992783 100644
--- a/homeassistant/components/brother/manifest.json
+++ b/homeassistant/components/brother/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
- "requirements": ["brother==5.0.1"],
+ "requirements": ["brother==5.1.0"],
"zeroconf": [
{
"type": "_printer._tcp.local.",
diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json
index d0714a199c4..f5da85ebb77 100644
--- a/homeassistant/components/brother/strings.json
+++ b/homeassistant/components/brother/strings.json
@@ -8,7 +8,21 @@
"type": "Type of the printer"
},
"data_description": {
- "host": "The hostname or IP address of the Brother printer to control."
+ "host": "The hostname or IP address of the Brother printer to control.",
+ "type": "Brother printer type: ink or laser."
+ },
+ "sections": {
+ "advanced_settings": {
+ "name": "Advanced settings",
+ "data": {
+ "port": "[%key:common::config_flow::data::port%]",
+ "community": "SNMP Community"
+ },
+ "data_description": {
+ "port": "The SNMP port of the Brother printer.",
+ "community": "A simple password for devices to communicate to each other."
+ }
+ }
}
},
"zeroconf_confirm": {
@@ -16,6 +30,22 @@
"title": "Discovered Brother Printer",
"data": {
"type": "[%key:component::brother::config::step::user::data::type%]"
+ },
+ "data_description": {
+ "type": "[%key:component::brother::config::step::user::data_description::type%]"
+ },
+ "sections": {
+ "advanced_settings": {
+ "name": "Advanced settings",
+ "data": {
+ "port": "[%key:common::config_flow::data::port%]",
+ "community": "SNMP Community"
+ },
+ "data_description": {
+ "port": "The SNMP port of the Brother printer.",
+ "community": "A simple password for devices to communicate to each other."
+ }
+ }
}
},
"reconfigure": {
@@ -25,6 +55,19 @@
},
"data_description": {
"host": "[%key:component::brother::config::step::user::data_description::host%]"
+ },
+ "sections": {
+ "advanced_settings": {
+ "name": "Advanced settings",
+ "data": {
+ "port": "[%key:common::config_flow::data::port%]",
+ "community": "SNMP Community"
+ },
+ "data_description": {
+ "port": "The SNMP port of the Brother printer.",
+ "community": "A simple password for devices to communicate to each other."
+ }
+ }
}
}
},
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index bef0388a57d..5d181c07444 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -81,11 +81,15 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
+ if self.coordinator.data.state.current_temperature is None:
+ return None
return self.coordinator.data.state.current_temperature.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
+ if self.coordinator.data.state.target_temperature is None:
+ return None
return self.coordinator.data.state.target_temperature.value
@property
diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py
index 5f4f67a114a..72e053ad140 100644
--- a/homeassistant/components/bsblan/config_flow.py
+++ b/homeassistant/components/bsblan/config_flow.py
@@ -25,7 +25,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize BSBLan flow."""
- self.host: str | None = None
+ self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.passkey: str | None = None
diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py
index 7f3f7f48afc..f28c7a2decf 100644
--- a/homeassistant/components/bsblan/sensor.py
+++ b/homeassistant/components/bsblan/sensor.py
@@ -28,6 +28,7 @@ class BSBLanSensorEntityDescription(SensorEntityDescription):
"""Describes BSB-Lan sensor entity."""
value_fn: Callable[[BSBLanCoordinatorData], StateType]
+ exists_fn: Callable[[BSBLanCoordinatorData], bool] = lambda data: True
SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
@@ -37,7 +38,12 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.sensor.current_temperature.value,
+ value_fn=lambda data: (
+ data.sensor.current_temperature.value
+ if data.sensor.current_temperature is not None
+ else None
+ ),
+ exists_fn=lambda data: data.sensor.current_temperature is not None,
),
BSBLanSensorEntityDescription(
key="outside_temperature",
@@ -45,7 +51,12 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda data: data.sensor.outside_temperature.value,
+ value_fn=lambda data: (
+ data.sensor.outside_temperature.value
+ if data.sensor.outside_temperature is not None
+ else None
+ ),
+ exists_fn=lambda data: data.sensor.outside_temperature is not None,
),
)
@@ -57,7 +68,16 @@ async def async_setup_entry(
) -> None:
"""Set up BSB-Lan sensor based on a config entry."""
data = entry.runtime_data
- async_add_entities(BSBLanSensor(data, description) for description in SENSOR_TYPES)
+
+ # Only create sensors for available data points
+ entities = [
+ BSBLanSensor(data, description)
+ for description in SENSOR_TYPES
+ if description.exists_fn(data.coordinator.data)
+ ]
+
+ if entities:
+ async_add_entities(entities)
class BSBLanSensor(BSBLanEntity, SensorEntity):
diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json
index b27be62e052..7fceeeeee00 100644
--- a/homeassistant/components/bsblan/strings.json
+++ b/homeassistant/components/bsblan/strings.json
@@ -85,10 +85,10 @@
"entity": {
"sensor": {
"current_temperature": {
- "name": "Current Temperature"
+ "name": "Current temperature"
},
"outside_temperature": {
- "name": "Outside Temperature"
+ "name": "Outside temperature"
}
}
}
diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py
index a3aee4cdc15..248d7def849 100644
--- a/homeassistant/components/bsblan/water_heater.py
+++ b/homeassistant/components/bsblan/water_heater.py
@@ -41,6 +41,18 @@ async def async_setup_entry(
) -> None:
"""Set up BSBLAN water heater based on a config entry."""
data = entry.runtime_data
+
+ # Only create water heater entity if DHW (Domestic Hot Water) is available
+ # Check if we have any DHW-related data indicating water heater support
+ dhw_data = data.coordinator.data.dhw
+ if (
+ dhw_data.operating_mode is None
+ and dhw_data.nominal_setpoint is None
+ and dhw_data.dhw_actual_value_top_temperature is None
+ ):
+ # No DHW functionality available, skip water heater setup
+ return
+
async_add_entities([BSBLANWaterHeater(data)])
@@ -61,23 +73,31 @@ class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity):
# Set temperature limits based on device capabilities
self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
- self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value
- self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value
+ if data.coordinator.data.dhw.reduced_setpoint is not None:
+ self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value
+ if data.coordinator.data.dhw.nominal_setpoint_max is not None:
+ self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value
@property
def current_operation(self) -> str | None:
"""Return current operation."""
+ if self.coordinator.data.dhw.operating_mode is None:
+ return None
current_mode = self.coordinator.data.dhw.operating_mode.desc
return OPERATION_MODES.get(current_mode)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
+ if self.coordinator.data.dhw.dhw_actual_value_top_temperature is None:
+ return None
return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
+ if self.coordinator.data.dhw.nominal_setpoint is None:
+ return None
return self.coordinator.data.dhw.nominal_setpoint.value
async def async_set_temperature(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json
index 0bbdfae50e4..86cab723a07 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.13.1"]
+ "requirements": ["bthome-ble==3.14.2"]
}
diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py
index dbabad96041..08d52efda09 100644
--- a/homeassistant/components/bthome/sensor.py
+++ b/homeassistant/components/bthome/sensor.py
@@ -25,6 +25,7 @@ from homeassistant.const import (
DEGREE,
LIGHT_LUX,
PERCENTAGE,
+ REVOLUTIONS_PER_MINUTE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfConductivity,
@@ -269,6 +270,15 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
),
+ # Rotational speed (rpm)
+ (
+ BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED,
+ Units.REVOLUTIONS_PER_MINUTE,
+ ): SensorEntityDescription(
+ key=f"{BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED}_{Units.REVOLUTIONS_PER_MINUTE}",
+ native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
# Signal Strength (RSSI) (dB)
(
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py
index 96bf717c3ac..8f8d04a7c4c 100644
--- a/homeassistant/components/calendar/__init__.py
+++ b/homeassistant/components/calendar/__init__.py
@@ -315,9 +315,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(CalendarListView(component))
hass.http.register_view(CalendarEventView(component))
- frontend.async_register_built_in_panel(
- hass, "calendar", "calendar", "hass:calendar"
- )
+ frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar")
websocket_api.async_register_command(hass, handle_calendar_event_create)
websocket_api.async_register_command(hass, handle_calendar_event_delete)
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index 4286e7462cc..fe7510c3bf5 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -51,12 +51,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval
@@ -81,7 +75,11 @@ from .const import (
)
from .helper import get_camera_from_entity_id
from .img_util import scale_jpeg_camera_image
-from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
+from .prefs import (
+ CameraPreferences,
+ DynamicStreamSettings, # noqa: F401
+ get_dynamic_camera_stream_settings,
+)
from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider,
@@ -114,12 +112,6 @@ ATTR_FILENAME: Final = "filename"
ATTR_MEDIA_PLAYER: Final = "media_player"
ATTR_FORMAT: Final = "format"
-# These constants are deprecated as of Home Assistant 2024.10
-# Please use the StreamType enum instead.
-_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10")
-_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10")
-_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10")
-
class CameraEntityFeature(IntFlag):
"""Supported features of the camera entity."""
@@ -550,9 +542,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self.hass,
source,
options=self.stream_options,
- dynamic_stream_settings=await self.hass.data[
- DATA_CAMERA_PREFS
- ].get_dynamic_stream_settings(self.entity_id),
+ dynamic_stream_settings=await get_dynamic_camera_stream_settings(
+ self.hass, self.entity_id
+ ),
stream_label=self.entity_id,
)
self.stream.set_update_callback(self.async_write_ha_state)
@@ -942,9 +934,7 @@ async def websocket_get_prefs(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle request for account info."""
- stream_prefs = await hass.data[DATA_CAMERA_PREFS].get_dynamic_stream_settings(
- msg["entity_id"]
- )
+ stream_prefs = await get_dynamic_camera_stream_settings(hass, msg["entity_id"])
connection.send_result(msg["id"], asdict(stream_prefs))
@@ -1115,11 +1105,3 @@ async def async_handle_record_service(
duration=service_call.data[CONF_DURATION],
lookback=service_call.data[CONF_LOOKBACK],
)
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py
index 2eccaf500e1..ceeb050b899 100644
--- a/homeassistant/components/camera/prefs.py
+++ b/homeassistant/components/camera/prefs.py
@@ -13,7 +13,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
-from .const import DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM
+from .const import DATA_CAMERA_PREFS, DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM
STORAGE_KEY: Final = DOMAIN
STORAGE_VERSION: Final = 1
@@ -106,3 +106,12 @@ class CameraPreferences:
)
self._dynamic_stream_settings_by_entity_id[entity_id] = settings
return settings
+
+
+async def get_dynamic_camera_stream_settings(
+ hass: HomeAssistant, entity_id: str
+) -> DynamicStreamSettings:
+ """Get dynamic stream settings for a camera entity."""
+ if DATA_CAMERA_PREFS not in hass.data:
+ raise HomeAssistantError("Camera integration not set up")
+ return await hass.data[DATA_CAMERA_PREFS].get_dynamic_stream_settings(entity_id)
diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py
index 4d956205990..3fc284cda8b 100644
--- a/homeassistant/components/cast/discovery.py
+++ b/homeassistant/components/cast/discovery.py
@@ -3,7 +3,8 @@
import logging
import threading
-import pychromecast
+import pychromecast.discovery
+import pychromecast.models
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py
index c45bbb4fbbc..2948c30fd1a 100644
--- a/homeassistant/components/cast/helpers.py
+++ b/homeassistant/components/cast/helpers.py
@@ -11,10 +11,13 @@ from uuid import UUID
import aiohttp
import attr
-import pychromecast
from pychromecast import dial
from pychromecast.const import CAST_TYPE_GROUP
+import pychromecast.controllers.media
+import pychromecast.controllers.multizone
+import pychromecast.controllers.receiver
from pychromecast.models import CastInfo
+import pychromecast.socket_client
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index e17360127b9..6d05fa81f3a 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -10,8 +10,10 @@ import json
import logging
from typing import TYPE_CHECKING, Any, Concatenate
-import pychromecast
+import pychromecast.config
+import pychromecast.const
from pychromecast.controllers.homeassistant import HomeAssistantController
+import pychromecast.controllers.media
from pychromecast.controllers.media import (
MEDIA_PLAYER_ERROR_CODES,
MEDIA_PLAYER_STATE_BUFFERING,
diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py
index df321395b9e..f4a68acc322 100644
--- a/homeassistant/components/ccm15/climate.py
+++ b/homeassistant/components/ccm15/climate.py
@@ -3,9 +3,10 @@
import logging
from typing import Any
-from ccm15 import CCM15DeviceState
+from ccm15 import CCM15DeviceState, CCM15SlaveDevice
from homeassistant.components.climate import (
+ ATTR_HVAC_MODE,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
@@ -88,7 +89,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
)
@property
- def data(self) -> CCM15DeviceState | None:
+ def data(self) -> CCM15SlaveDevice | None:
"""Return device data."""
return self.coordinator.get_ac_data(self._ac_index)
@@ -144,15 +145,17 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
- await self.coordinator.async_set_temperature(self._ac_index, temperature)
+ await self.coordinator.async_set_temperature(
+ self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE)
+ )
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the hvac mode."""
- await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode)
+ await self.coordinator.async_set_hvac_mode(self._ac_index, self.data, hvac_mode)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
- await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode)
+ await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode)
async def async_turn_off(self) -> None:
"""Turn off."""
diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py
index 03a59aa3f24..ad3bbc41a06 100644
--- a/homeassistant/components/ccm15/coordinator.py
+++ b/homeassistant/components/ccm15/coordinator.py
@@ -55,9 +55,9 @@ class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
"""Get the current status of all AC devices."""
return await self._ccm15.get_status_async()
- async def async_set_state(self, ac_index: int, state: str, value: int) -> None:
+ async def async_set_state(self, ac_index: int, data) -> None:
"""Set new target states."""
- if await self._ccm15.async_set_state(ac_index, state, value):
+ if await self._ccm15.async_set_state(ac_index, data):
await self.async_request_refresh()
def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None:
@@ -67,17 +67,32 @@ class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
return None
return self.data.devices[ac_index]
- async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None:
- """Set the hvac mode."""
+ async def async_set_hvac_mode(
+ self, ac_index: int, data: CCM15SlaveDevice, hvac_mode: HVACMode
+ ) -> None:
+ """Set the HVAC mode."""
_LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode))
- await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode])
+ data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode]
+ await self.async_set_state(ac_index, data)
- async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None:
+ async def async_set_fan_mode(
+ self, ac_index: int, data: CCM15SlaveDevice, fan_mode: str
+ ) -> None:
"""Set the fan mode."""
_LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode)
- await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode])
+ data.fan_mode = CONST_FAN_CMD_MAP[fan_mode]
+ await self.async_set_state(ac_index, data)
- async def async_set_temperature(self, ac_index, temp) -> None:
+ async def async_set_temperature(
+ self,
+ ac_index: int,
+ data: CCM15SlaveDevice,
+ temp: int,
+ hvac_mode: HVACMode | None,
+ ) -> None:
"""Set the target temperature mode."""
_LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp)
- await self.async_set_state(ac_index, "temp", temp)
+ data.temperature_setpoint = temp
+ if hvac_mode is not None:
+ data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode]
+ await self.async_set_state(ac_index, data)
diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json
index 2d985d6148a..23cd5547963 100644
--- a/homeassistant/components/ccm15/manifest.json
+++ b/homeassistant/components/ccm15/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ccm15",
"iot_class": "local_polling",
- "requirements": ["py-ccm15==0.0.9"]
+ "requirements": ["py_ccm15==0.1.2"]
}
diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py
index 7691a2db0f1..6f820ce0837 100644
--- a/homeassistant/components/climate/intent.py
+++ b/homeassistant/components/climate/intent.py
@@ -89,7 +89,6 @@ class SetTemperatureIntent(intent.IntentHandler):
)
response = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
success_results=[
intent.IntentResponseTarget(
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index ad0bccb25ce..a75d327924a 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -274,16 +274,16 @@
"message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}."
},
"low_temp_higher_than_high_temp": {
- "message": "Target temperature low can not be higher than Target temperature high."
+ "message": "'Lower target temperature' can not be higher than 'Upper target temperature'."
},
"humidity_out_of_range": {
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
},
"missing_target_temperature_entity_feature": {
- "message": "Set temperature action was used with the target temperature parameter but the entity does not support it."
+ "message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it."
},
"missing_target_temperature_range_entity_feature": {
- "message": "Set temperature action was used with the target temperature low/high parameter but the entity does not support it."
+ "message": "Set temperature action was used with the 'Lower/Upper target temperature' parameter but the entity does not support it."
}
}
}
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 2c7c6f80d49..7b025501d0c 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -53,7 +53,6 @@ from .const import (
CONF_ACME_SERVER,
CONF_ALEXA,
CONF_ALIASES,
- CONF_CLOUDHOOK_SERVER,
CONF_COGNITO_CLIENT_ID,
CONF_ENTITY_CONFIG,
CONF_FILTER,
@@ -130,7 +129,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
- vol.Optional(CONF_CLOUDHOOK_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,
vol.Optional(CONF_REMOTESTATE_SERVER): str,
vol.Optional(CONF_SERVICEHANDLERS_SERVER): str,
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 1f154832ef9..23f857b9bff 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -78,7 +78,6 @@ 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_CLOUDHOOK_SERVER = "cloudhook_server"
CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 49e4af9e3e5..4a8a569a5a6 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -37,6 +37,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.loader import (
+ async_get_custom_components,
+ async_get_loaded_integration,
+)
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
+ async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
+ """Collect information about active and custom integrations."""
+ # Get loaded components from hass.config.components
+ loaded_components = hass.config.components.copy()
+
+ # Get custom integrations
+ custom_domains = set()
+ with suppress(Exception):
+ custom_domains = set(await async_get_custom_components(hass))
+
+ # Separate built-in and custom integrations
+ builtin_integrations = []
+ custom_integrations = []
+
+ for domain in sorted(loaded_components):
+ try:
+ integration = async_get_loaded_integration(hass, domain)
+ except Exception: # noqa: BLE001
+ # Broad exception catch for robustness in support package
+ # generation. If we can't get integration info,
+ # just add the domain
+ if domain in custom_domains:
+ custom_integrations.append(
+ {
+ "domain": domain,
+ "name": "Unknown",
+ "version": "Unknown",
+ "documentation": "Unknown",
+ }
+ )
+ else:
+ builtin_integrations.append(
+ {
+ "domain": domain,
+ "name": "Unknown",
+ }
+ )
+ else:
+ if domain in custom_domains:
+ # This is a custom integration
+ # include version and documentation link
+ version = (
+ str(integration.version) if integration.version else "Unknown"
+ )
+ if not (documentation := integration.documentation):
+ documentation = "Unknown"
+
+ custom_integrations.append(
+ {
+ "domain": domain,
+ "name": integration.name,
+ "version": version,
+ "documentation": documentation,
+ }
+ )
+ else:
+ # This is a built-in integration.
+ # No version needed, as it is always the same as the
+ # Home Assistant version
+ builtin_integrations.append(
+ {
+ "domain": domain,
+ "name": integration.name,
+ }
+ )
+
+ return {
+ "builtin_count": len(builtin_integrations),
+ "builtin_integrations": builtin_integrations,
+ "custom_count": len(custom_integrations),
+ "custom_integrations": custom_integrations,
+ }
+
async def _generate_markdown(
self,
hass: HomeAssistant,
@@ -453,6 +530,38 @@ class DownloadSupportPackageView(HomeAssistantView):
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
+ # Add integration information
+ try:
+ integration_info = await self._get_integration_info(hass)
+ except Exception: # noqa: BLE001
+ # Broad exception catch for robustness in support package generation
+ # If there's any error getting integration info, just note it
+ markdown += "## Active integrations\n\n"
+ markdown += "Unable to collect integration information\n\n"
+ else:
+ markdown += "## Active Integrations\n\n"
+ markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
+ markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
+
+ # Built-in integrations
+ if integration_info["builtin_integrations"]:
+ markdown += "Built-in integrations
\n\n"
+ markdown += "Domain | Name\n"
+ markdown += "--- | ---\n"
+ for integration in integration_info["builtin_integrations"]:
+ markdown += f"{integration['domain']} | {integration['name']}\n"
+ markdown += "\n \n\n"
+
+ # Custom integrations
+ if integration_info["custom_integrations"]:
+ markdown += "Custom integrations
\n\n"
+ markdown += "Domain | Name | Version | Documentation\n"
+ markdown += "--- | --- | --- | ---\n"
+ for integration in integration_info["custom_integrations"]:
+ doc_url = integration.get("documentation") or "N/A"
+ markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
+ markdown += "\n \n\n"
+
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index a0f88b3a558..134c9127512 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.0.0"],
+ "requirements": ["hass-nabucasa==1.2.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py
index c1b8fc095c3..980823243bc 100644
--- a/homeassistant/components/cloud/subscription.py
+++ b/homeassistant/components/cloud/subscription.py
@@ -25,7 +25,11 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
return await cloud.payments.subscription_info()
except PaymentsApiError as exception:
_LOGGER.error("Failed to fetch subscription information - %s", exception)
-
+ except TimeoutError:
+ _LOGGER.error(
+ "A timeout of %s was reached while trying to fetch subscription information",
+ REQUEST_TIMEOUT,
+ )
return None
diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py
index be2036292e3..f29f3c72f1f 100644
--- a/homeassistant/components/co2signal/coordinator.py
+++ b/homeassistant/components/co2signal/coordinator.py
@@ -6,10 +6,10 @@ from datetime import timedelta
import logging
from aioelectricitymaps import (
- CarbonIntensityResponse,
ElectricityMaps,
ElectricityMapsError,
ElectricityMapsInvalidTokenError,
+ HomeAssistantCarbonIntensityResponse,
)
from homeassistant.config_entries import ConfigEntry
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator]
-class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]):
+class CO2SignalCoordinator(DataUpdateCoordinator[HomeAssistantCarbonIntensityResponse]):
"""Data update coordinator."""
config_entry: CO2SignalConfigEntry
@@ -51,7 +51,7 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]):
"""Return entry ID."""
return self.config_entry.entry_id
- async def _async_update_data(self) -> CarbonIntensityResponse:
+ async def _async_update_data(self) -> HomeAssistantCarbonIntensityResponse:
"""Fetch the latest data from the source."""
try:
diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py
index 3feabef2fdd..b76e990c8f3 100644
--- a/homeassistant/components/co2signal/helpers.py
+++ b/homeassistant/components/co2signal/helpers.py
@@ -5,8 +5,12 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from aioelectricitymaps import ElectricityMaps
-from aioelectricitymaps.models import CarbonIntensityResponse
+from aioelectricitymaps import (
+ CoordinatesRequest,
+ ElectricityMaps,
+ HomeAssistantCarbonIntensityResponse,
+ ZoneRequest,
+)
from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
@@ -16,14 +20,16 @@ async def fetch_latest_carbon_intensity(
hass: HomeAssistant,
em: ElectricityMaps,
config: Mapping[str, Any],
-) -> CarbonIntensityResponse:
+) -> HomeAssistantCarbonIntensityResponse:
"""Fetch the latest carbon intensity based on country code or location coordinates."""
- if CONF_COUNTRY_CODE in config:
- return await em.latest_carbon_intensity_by_country_code(
- code=config[CONF_COUNTRY_CODE]
- )
-
- return await em.latest_carbon_intensity_by_coordinates(
+ request: CoordinatesRequest | ZoneRequest = CoordinatesRequest(
lat=config.get(CONF_LATITUDE, hass.config.latitude),
lon=config.get(CONF_LONGITUDE, hass.config.longitude),
)
+
+ if CONF_COUNTRY_CODE in config:
+ request = ZoneRequest(
+ zone=config[CONF_COUNTRY_CODE],
+ )
+
+ return await em.carbon_intensity_for_home_assistant(request)
diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json
index ff6d5bdb18b..106fd0686af 100644
--- a/homeassistant/components/co2signal/manifest.json
+++ b/homeassistant/components/co2signal/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioelectricitymaps"],
- "requirements": ["aioelectricitymaps==0.4.0"]
+ "requirements": ["aioelectricitymaps==1.1.1"]
}
diff --git a/homeassistant/components/co2signal/quality_scale.yaml b/homeassistant/components/co2signal/quality_scale.yaml
new file mode 100644
index 00000000000..d2ddb091e5e
--- /dev/null
+++ b/homeassistant/components/co2signal/quality_scale.yaml
@@ -0,0 +1,106 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage:
+ status: todo
+ comment: |
+ Stale docstring and test name: `test_form_home` and reusing result.
+ Extract `async_setup_entry` into own fixture.
+ Avoid importing `config_flow` in tests.
+ Test reauth with errors
+ config-flow:
+ status: todo
+ comment: |
+ The config flow misses data descriptions.
+ Remove URLs from data descriptions, they should be replaced with placeholders.
+ Make use of Electricity Maps zone keys in country code as dropdown.
+ Make use of location selector for coordinates.
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration do not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: todo
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ The integration does not provide any actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ The integration does not provide any additional options.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: |
+ Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})`
+ `test_sensor` could use `snapshot_platform`
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ The integration connects to a single service per configuration entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single device per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py
index a8e962532b8..9cf5ae4c9a7 100644
--- a/homeassistant/components/co2signal/sensor.py
+++ b/homeassistant/components/co2signal/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from aioelectricitymaps.models import CarbonIntensityResponse
+from aioelectricitymaps import HomeAssistantCarbonIntensityResponse
from homeassistant.components.sensor import (
SensorEntity,
@@ -28,10 +28,10 @@ class CO2SensorEntityDescription(SensorEntityDescription):
# For backwards compat, allow description to override unique ID key to use
unique_id: str | None = None
- unit_of_measurement_fn: Callable[[CarbonIntensityResponse], str | None] | None = (
- None
- )
- value_fn: Callable[[CarbonIntensityResponse], float | None]
+ unit_of_measurement_fn: (
+ Callable[[HomeAssistantCarbonIntensityResponse], str | None] | None
+ ) = None
+ value_fn: Callable[[HomeAssistantCarbonIntensityResponse], float | None]
SENSORS = (
diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py
index e1be330afae..68390642c87 100644
--- a/homeassistant/components/comelit/binary_sensor.py
+++ b/homeassistant/components/comelit/binary_sensor.py
@@ -29,10 +29,23 @@ async def async_setup_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
- async_add_entities(
- ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
- for device in coordinator.data["alarm_zones"].values()
- )
+ known_devices: set[int] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data["alarm_zones"])
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ ComelitVedoBinarySensorEntity(
+ coordinator, device, config_entry.entry_id
+ )
+ for device in coordinator.data["alarm_zones"].values()
+ if device.index in new_devices
+ )
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitVedoBinarySensorEntity(
diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py
index 5b09b582c66..0f47d88fad1 100644
--- a/homeassistant/components/comelit/config_flow.py
+++ b/homeassistant/components/comelit/config_flow.py
@@ -25,23 +25,27 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
from .utils import async_client_session
DEFAULT_HOST = "192.168.1.252"
-DEFAULT_PIN = 111111
+DEFAULT_PIN = "111111"
+pin_regex = r"^[0-9]{4,10}$"
+
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
+ vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
}
)
-STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int})
+STEP_REAUTH_DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_PIN): cv.matches_regex(pin_regex)}
+)
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
- vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
+ vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex),
}
)
diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py
index a5a90c07568..8818e296e03 100644
--- a/homeassistant/components/comelit/coordinator.py
+++ b/homeassistant/components/comelit/coordinator.py
@@ -2,7 +2,7 @@
from abc import abstractmethod
from datetime import timedelta
-from typing import TypeVar
+from typing import Any, TypeVar
from aiocomelit.api import (
AlarmDataObject,
@@ -13,7 +13,16 @@ from aiocomelit.api import (
ComelitVedoAreaObject,
ComelitVedoZoneObject,
)
-from aiocomelit.const import BRIDGE, VEDO
+from aiocomelit.const import (
+ BRIDGE,
+ CLIMATE,
+ COVER,
+ IRRIGATION,
+ LIGHT,
+ OTHER,
+ SCENARIO,
+ VEDO,
+)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession
@@ -111,6 +120,32 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
async def _async_update_system_data(self) -> T:
"""Class method for updating data."""
+ async def _async_remove_stale_devices(
+ self,
+ previous_list: dict[int, Any],
+ current_list: dict[int, Any],
+ dev_type: str,
+ ) -> None:
+ """Remove stale devices."""
+ device_registry = dr.async_get(self.hass)
+
+ for i in previous_list:
+ if i not in current_list:
+ _LOGGER.debug(
+ "Detected change in %s devices: index %s removed",
+ dev_type,
+ i,
+ )
+ identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}"
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, identifier)}
+ )
+ if device:
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+
class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
@@ -137,7 +172,15 @@ class ComelitSerialBridge(
self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data."""
- return await self.api.get_all_devices()
+ data = await self.api.get_all_devices()
+
+ if self.data:
+ for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
+ await self._async_remove_stale_devices(
+ self.data[dev_type], data[dev_type], dev_type
+ )
+
+ return data
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
@@ -163,4 +206,14 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
self,
) -> AlarmDataObject:
"""Specific method for updating data."""
- return await self.api.get_all_areas_and_zones()
+ data = await self.api.get_all_areas_and_zones()
+
+ if self.data:
+ for obj_type in ("alarm_areas", "alarm_zones"):
+ await self._async_remove_stale_devices(
+ self.data[obj_type],
+ data[obj_type],
+ "area" if obj_type == "alarm_areas" else "zone",
+ )
+
+ return data
diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py
index 691ebaec638..70525ffe712 100644
--- a/homeassistant/components/comelit/cover.py
+++ b/homeassistant/components/comelit/cover.py
@@ -29,10 +29,21 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
- async_add_entities(
- ComelitCoverEntity(coordinator, device, config_entry.entry_id)
- for device in coordinator.data[COVER].values()
- )
+ known_devices: set[int] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data[COVER])
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ ComelitCoverEntity(coordinator, device, config_entry.entry_id)
+ for device in coordinator.data[COVER].values()
+ if device.index in new_devices
+ )
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py
index c04b88c7819..8ff626ed916 100644
--- a/homeassistant/components/comelit/light.py
+++ b/homeassistant/components/comelit/light.py
@@ -27,10 +27,21 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
- async_add_entities(
- ComelitLightEntity(coordinator, device, config_entry.entry_id)
- for device in coordinator.data[LIGHT].values()
- )
+ known_devices: set[int] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data[LIGHT])
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ ComelitLightEntity(coordinator, device, config_entry.entry_id)
+ for device in coordinator.data[LIGHT].values()
+ if device.index in new_devices
+ )
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json
index 44101f0fd06..4e8fee1bba6 100644
--- a/homeassistant/components/comelit/manifest.json
+++ b/homeassistant/components/comelit/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
- "quality_scale": "silver",
+ "quality_scale": "platinum",
"requirements": ["aiocomelit==0.12.3"]
}
diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml
index 4fbbd79d60d..21c54e00679 100644
--- a/homeassistant/components/comelit/quality_scale.yaml
+++ b/homeassistant/components/comelit/quality_scale.yaml
@@ -57,9 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
- dynamic-devices:
- status: todo
- comment: missing implementation
+ dynamic-devices: done
entity-category:
status: exempt
comment: no config or diagnostic entities
@@ -72,9 +70,7 @@ rules:
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
- stale-devices:
- status: todo
- comment: missing implementation
+ stale-devices: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py
index a11cac4e1c0..f47a8872368 100644
--- a/homeassistant/components/comelit/sensor.py
+++ b/homeassistant/components/comelit/sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Final, cast
-from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
+from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from homeassistant.components.sensor import (
@@ -65,15 +65,24 @@ async def async_setup_bridge_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
- entities: list[ComelitBridgeSensorEntity] = []
- for device in coordinator.data[OTHER].values():
- entities.extend(
- ComelitBridgeSensorEntity(
- coordinator, device, config_entry.entry_id, sensor_desc
+ known_devices: set[int] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data[OTHER])
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ ComelitBridgeSensorEntity(
+ coordinator, device, config_entry.entry_id, sensor_desc
+ )
+ for sensor_desc in SENSOR_BRIDGE_TYPES
+ for device in coordinator.data[OTHER].values()
+ if device.index in new_devices
)
- for sensor_desc in SENSOR_BRIDGE_TYPES
- )
- async_add_entities(entities)
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
async def async_setup_vedo_entry(
@@ -85,15 +94,24 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
- entities: list[ComelitVedoSensorEntity] = []
- for device in coordinator.data["alarm_zones"].values():
- entities.extend(
- ComelitVedoSensorEntity(
- coordinator, device, config_entry.entry_id, sensor_desc
+ known_devices: set[int] = set()
+
+ def _check_device() -> None:
+ current_devices = set(coordinator.data["alarm_zones"])
+ new_devices = current_devices - known_devices
+ if new_devices:
+ known_devices.update(new_devices)
+ async_add_entities(
+ ComelitVedoSensorEntity(
+ coordinator, device, config_entry.entry_id, sensor_desc
+ )
+ for sensor_desc in SENSOR_VEDO_TYPES
+ for device in coordinator.data["alarm_zones"].values()
+ if device.index in new_devices
)
- for sensor_desc in SENSOR_VEDO_TYPES
- )
- async_add_entities(entities)
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py
index 1896071596f..076b6091a3d 100644
--- a/homeassistant/components/comelit/switch.py
+++ b/homeassistant/components/comelit/switch.py
@@ -39,6 +39,25 @@ async def async_setup_entry(
)
async_add_entities(entities)
+ known_devices: dict[str, set[int]] = {
+ dev_type: set() for dev_type in (IRRIGATION, OTHER)
+ }
+
+ def _check_device() -> None:
+ for dev_type in (IRRIGATION, OTHER):
+ current_devices = set(coordinator.data[dev_type])
+ new_devices = current_devices - known_devices[dev_type]
+ if new_devices:
+ known_devices[dev_type].update(new_devices)
+ async_add_entities(
+ ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
+ for device in coordinator.data[dev_type].values()
+ if device.index in new_devices
+ )
+
+ _check_device()
+ config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
+
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device."""
diff --git a/homeassistant/components/compit/__init__.py b/homeassistant/components/compit/__init__.py
new file mode 100644
index 00000000000..b4802181da9
--- /dev/null
+++ b/homeassistant/components/compit/__init__.py
@@ -0,0 +1,45 @@
+"""The Compit integration."""
+
+from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
+
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
+
+PLATFORMS = [
+ Platform.CLIMATE,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
+ """Set up Compit from a config entry."""
+
+ session = async_get_clientsession(hass)
+ connector = CompitApiConnector(session)
+ try:
+ connected = await connector.init(
+ entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language
+ )
+ except CannotConnect as e:
+ raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e
+ except InvalidAuth as e:
+ raise ConfigEntryAuthFailed(
+ f"Invalid credentials for {entry.data[CONF_EMAIL]}"
+ ) from e
+
+ if not connected:
+ raise ConfigEntryAuthFailed("Authentication API error")
+
+ coordinator = CompitDataUpdateCoordinator(hass, entry, connector)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
+ """Unload an entry for the Compit integration."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/compit/climate.py b/homeassistant/components/compit/climate.py
new file mode 100644
index 00000000000..5647b3b5826
--- /dev/null
+++ b/homeassistant/components/compit/climate.py
@@ -0,0 +1,265 @@
+"""Module contains the CompitClimate class for controlling climate entities."""
+
+import logging
+from typing import Any
+
+from compit_inext_api import Param, Parameter
+from compit_inext_api.consts import (
+ CompitFanMode,
+ CompitHVACMode,
+ CompitParameter,
+ CompitPresetMode,
+)
+from propcache.api import cached_property
+
+from homeassistant.components.climate import (
+ FAN_AUTO,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ FAN_OFF,
+ PRESET_AWAY,
+ PRESET_ECO,
+ PRESET_HOME,
+ PRESET_NONE,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+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, MANUFACTURER_NAME
+from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
+
+_LOGGER: logging.Logger = logging.getLogger(__name__)
+
+# Device class for climate devices in Compit system
+CLIMATE_DEVICE_CLASS = 10
+PARALLEL_UPDATES = 0
+
+COMPIT_MODE_MAP = {
+ CompitHVACMode.COOL: HVACMode.COOL,
+ CompitHVACMode.HEAT: HVACMode.HEAT,
+ CompitHVACMode.OFF: HVACMode.OFF,
+}
+
+COMPIT_FANSPEED_MAP = {
+ CompitFanMode.OFF: FAN_OFF,
+ CompitFanMode.AUTO: FAN_AUTO,
+ CompitFanMode.LOW: FAN_LOW,
+ CompitFanMode.MEDIUM: FAN_MEDIUM,
+ CompitFanMode.HIGH: FAN_HIGH,
+ CompitFanMode.HOLIDAY: FAN_AUTO,
+}
+
+COMPIT_PRESET_MAP = {
+ CompitPresetMode.AUTO: PRESET_HOME,
+ CompitPresetMode.HOLIDAY: PRESET_ECO,
+ CompitPresetMode.MANUAL: PRESET_NONE,
+ CompitPresetMode.AWAY: PRESET_AWAY,
+}
+
+HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
+FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
+PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: CompitConfigEntry,
+ async_add_devices: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the CompitClimate platform from a config entry."""
+
+ coordinator = entry.runtime_data
+ climate_entities = []
+ for device_id in coordinator.connector.all_devices:
+ device = coordinator.connector.all_devices[device_id]
+
+ if device.definition.device_class == CLIMATE_DEVICE_CLASS:
+ climate_entities.append(
+ CompitClimate(
+ coordinator,
+ device_id,
+ {
+ parameter.parameter_code: parameter
+ for parameter in device.definition.parameters
+ },
+ device.definition.name,
+ )
+ )
+
+ async_add_devices(climate_entities)
+
+
+class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
+ """Representation of a Compit climate device."""
+
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
+ _attr_name = None
+ _attr_has_entity_name = True
+ _attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.FAN_MODE
+ | ClimateEntityFeature.PRESET_MODE
+ )
+
+ def __init__(
+ self,
+ coordinator: CompitDataUpdateCoordinator,
+ device_id: int,
+ parameters: dict[str, Parameter],
+ device_name: str,
+ ) -> None:
+ """Initialize the climate device."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{device_name}_{device_id}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, str(device_id))},
+ name=device_name,
+ manufacturer=MANUFACTURER_NAME,
+ model=device_name,
+ )
+
+ self.parameters = parameters
+ self.device_id = device_id
+ self.available_presets: Parameter | None = self.parameters.get(
+ CompitParameter.PRESET_MODE.value
+ )
+ self.available_fan_modes: Parameter | None = self.parameters.get(
+ CompitParameter.FAN_MODE.value
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return (
+ super().available
+ and self.device_id in self.coordinator.connector.all_devices
+ )
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
+ if value is None:
+ return None
+ return float(value.value)
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Return the temperature we try to reach."""
+ value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
+ if value is None:
+ return None
+ return float(value.value)
+
+ @cached_property
+ def preset_modes(self) -> list[str] | None:
+ """Return the available preset modes."""
+ if self.available_presets is None or self.available_presets.details is None:
+ return []
+
+ preset_modes = []
+ for item in self.available_presets.details:
+ if item is not None:
+ ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
+ if ha_preset and ha_preset not in preset_modes:
+ preset_modes.append(ha_preset)
+
+ return preset_modes
+
+ @cached_property
+ def fan_modes(self) -> list[str] | None:
+ """Return the available fan modes."""
+ if self.available_fan_modes is None or self.available_fan_modes.details is None:
+ return []
+
+ fan_modes = []
+ for item in self.available_fan_modes.details:
+ if item is not None:
+ ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
+ if ha_fan_mode and ha_fan_mode not in fan_modes:
+ fan_modes.append(ha_fan_mode)
+
+ return fan_modes
+
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the current preset mode."""
+ preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
+
+ if preset_mode:
+ compit_preset_mode = CompitPresetMode(preset_mode.value)
+ return COMPIT_PRESET_MAP.get(compit_preset_mode)
+ return None
+
+ @property
+ def fan_mode(self) -> str | None:
+ """Return the current fan mode."""
+ fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
+ if fan_mode:
+ compit_fan_mode = CompitFanMode(fan_mode.value)
+ return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
+ return None
+
+ @property
+ def hvac_mode(self) -> HVACMode | None:
+ """Return the current HVAC mode."""
+ hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
+ if hvac_mode:
+ compit_hvac_mode = CompitHVACMode(hvac_mode.value)
+ return COMPIT_MODE_MAP.get(compit_hvac_mode)
+ return None
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ if temp is None:
+ raise ServiceValidationError("Temperature argument missing")
+ await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp)
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set new target HVAC mode."""
+
+ if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)):
+ raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}")
+
+ await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value)
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new target preset mode."""
+
+ compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode)
+ if compit_preset is None:
+ raise ServiceValidationError(f"Invalid preset mode: {preset_mode}")
+
+ await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value)
+
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
+ """Set new target fan mode."""
+
+ compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode)
+ if compit_fan_mode is None:
+ raise ServiceValidationError(f"Invalid fan mode: {fan_mode}")
+
+ await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value)
+
+ async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None:
+ """Call the API to set a parameter to a new value."""
+ await self.coordinator.connector.set_device_parameter(
+ self.device_id, parameter, value
+ )
+ self.async_write_ha_state()
+
+ def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
+ """Get the parameter value from the device state."""
+ return self.coordinator.connector.get_device_parameter(
+ self.device_id, parameter
+ )
diff --git a/homeassistant/components/compit/config_flow.py b/homeassistant/components/compit/config_flow.py
new file mode 100644
index 00000000000..3f41aec8f13
--- /dev/null
+++ b/homeassistant/components/compit/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow for Compit integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+STEP_REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+
+class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Compit."""
+
+ VERSION = 1
+
+ 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:
+ session = async_create_clientsession(self.hass)
+ api = CompitApiConnector(session)
+ success = False
+ try:
+ success = await api.init(
+ user_input[CONF_EMAIL],
+ user_input[CONF_PASSWORD],
+ self.hass.config.language,
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if not success:
+ # Api returned unexpected result but no exception
+ _LOGGER.error("Compit api returned unexpected result")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(user_input[CONF_EMAIL])
+
+ if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data_updates=user_input
+ )
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=user_input[CONF_EMAIL], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
+ """Handle re-auth."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm re-authentication."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ reauth_entry_data = reauth_entry.data
+
+ if user_input:
+ # Reuse async_step_user with combined credentials
+ return await self.async_step_user(
+ {
+ CONF_EMAIL: reauth_entry_data[CONF_EMAIL],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ }
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=STEP_REAUTH_SCHEMA,
+ description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]},
+ errors=errors,
+ )
diff --git a/homeassistant/components/compit/const.py b/homeassistant/components/compit/const.py
new file mode 100644
index 00000000000..547012e706c
--- /dev/null
+++ b/homeassistant/components/compit/const.py
@@ -0,0 +1,4 @@
+"""Constants for the Compit integration."""
+
+DOMAIN = "compit"
+MANUFACTURER_NAME = "Compit"
diff --git a/homeassistant/components/compit/coordinator.py b/homeassistant/components/compit/coordinator.py
new file mode 100644
index 00000000000..98668b26039
--- /dev/null
+++ b/homeassistant/components/compit/coordinator.py
@@ -0,0 +1,43 @@
+"""Define an object to manage fetching Compit data."""
+
+from datetime import timedelta
+import logging
+
+from compit_inext_api import CompitApiConnector, DeviceInstance
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN
+
+SCAN_INTERVAL = timedelta(seconds=30)
+_LOGGER: logging.Logger = logging.getLogger(__name__)
+
+type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator]
+
+
+class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]):
+ """Class to manage fetching data from the API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ connector: CompitApiConnector,
+ ) -> None:
+ """Initialize."""
+ self.connector = connector
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ config_entry=config_entry,
+ )
+
+ async def _async_update_data(self) -> dict[int, DeviceInstance]:
+ """Update data via library."""
+ await self.connector.update_state(device_id=None) # Update all devices
+ return self.connector.all_devices
diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json
new file mode 100644
index 00000000000..b686c406ad1
--- /dev/null
+++ b/homeassistant/components/compit/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "compit",
+ "name": "Compit",
+ "codeowners": ["@Przemko92"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/compit",
+ "integration_type": "hub",
+ "iot_class": "cloud_polling",
+ "loggers": ["compit"],
+ "quality_scale": "bronze",
+ "requirements": ["compit-inext-api==0.3.1"]
+}
diff --git a/homeassistant/components/compit/quality_scale.yaml b/homeassistant/components/compit/quality_scale.yaml
new file mode 100644
index 00000000000..88cdf4a47a4
--- /dev/null
+++ b/homeassistant/components/compit/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: exempt
+ comment: |
+ This integration does not use any common modules.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that should disabled by default.
+ entity-translations: done
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ There is no need for icon translations.
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json
new file mode 100644
index 00000000000..c043fe525f2
--- /dev/null
+++ b/homeassistant/components/compit/strings.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Please enter your https://inext.compit.pl/ credentials.",
+ "title": "Connect to Compit iNext",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address of your inext.compit.pl account",
+ "password": "The password of your inext.compit.pl account"
+ }
+ },
+ "reauth_confirm": {
+ "description": "Please update your password for {email}",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::compit::config::step::user::data_description::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ }
+}
diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py
index 1f6dc2c2122..ca4ddda2242 100644
--- a/homeassistant/components/config/__init__.py
+++ b/homeassistant/components/config/__init__.py
@@ -49,7 +49,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the config component."""
frontend.async_register_built_in_panel(
- hass, "config", "config", "hass:cog", require_admin=True
+ hass, "config", "config", "mdi:cog", require_admin=True
)
for panel in SECTIONS:
diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py
index 176c9e2b047..db82abd2096 100644
--- a/homeassistant/components/config/config_entries.py
+++ b/homeassistant/components/config/config_entries.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from http import HTTPStatus
+import logging
from typing import Any, NoReturn
from aiohttp import web
@@ -23,7 +24,12 @@ from homeassistant.helpers.data_entry_flow import (
FlowManagerResourceView,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.json import json_fragment
+from homeassistant.helpers.json import (
+ JSON_DUMP,
+ find_paths_unserializable_data,
+ json_bytes,
+ json_fragment,
+)
from homeassistant.loader import (
Integration,
IntegrationNotFound,
@@ -31,6 +37,9 @@ from homeassistant.loader import (
async_get_integrations,
async_get_loaded_integration,
)
+from homeassistant.util.json import format_unserializable_data
+
+_LOGGER = logging.getLogger(__name__)
@callback
@@ -62,6 +71,7 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, config_entries_flow_subscribe)
websocket_api.async_register_command(hass, ignore_config_flow)
+ websocket_api.async_register_command(hass, config_subentry_update)
websocket_api.async_register_command(hass, config_subentry_delete)
websocket_api.async_register_command(hass, config_subentry_list)
@@ -401,18 +411,40 @@ def config_entries_flow_subscribe(
connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
async_on_flow_init_remove
)
- connection.send_message(
- websocket_api.event_message(
- msg["id"],
- [
- {"type": None, "flow_id": flw["flow_id"], "flow": flw}
- for flw in hass.config_entries.flow.async_progress()
- if flw["context"]["source"]
- not in (
- config_entries.SOURCE_RECONFIGURE,
- config_entries.SOURCE_USER,
+ try:
+ serialized_flows = [
+ json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
+ for flw in hass.config_entries.flow.async_progress()
+ if flw["context"]["source"]
+ not in (
+ config_entries.SOURCE_RECONFIGURE,
+ config_entries.SOURCE_USER,
+ )
+ ]
+ except (ValueError, TypeError):
+ # If we can't serialize, we'll filter out unserializable flows
+ serialized_flows = []
+ for flw in hass.config_entries.flow.async_progress():
+ if flw["context"]["source"] in (
+ config_entries.SOURCE_RECONFIGURE,
+ config_entries.SOURCE_USER,
+ ):
+ continue
+ try:
+ serialized_flows.append(
+ json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw})
)
- ],
+ except (ValueError, TypeError):
+ _LOGGER.error(
+ "Unable to serialize to JSON. Bad data found at %s",
+ format_unserializable_data(
+ find_paths_unserializable_data(flw, dump=JSON_DUMP)
+ ),
+ )
+ continue
+ connection.send_message(
+ websocket_api.messages.construct_event_message(
+ msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]"))
)
)
connection.send_result(msg["id"])
@@ -731,6 +763,47 @@ async def config_subentry_list(
connection.send_result(msg["id"], result)
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ "type": "config_entries/subentries/update",
+ "entry_id": str,
+ "subentry_id": str,
+ vol.Optional("title"): str,
+ }
+)
+@websocket_api.async_response
+async def config_subentry_update(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Update a subentry of a config entry."""
+ entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
+ if entry is None:
+ connection.send_error(
+ msg["entry_id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
+ )
+ return
+
+ subentry = entry.subentries.get(msg["subentry_id"])
+ if subentry is None:
+ connection.send_error(
+ msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
+ )
+ return
+
+ changes = dict(msg)
+ changes.pop("id")
+ changes.pop("type")
+ changes.pop("entry_id")
+ changes.pop("subentry_id")
+
+ hass.config_entries.async_update_subentry(entry, subentry, **changes)
+
+ connection.send_result(msg["id"])
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py
index dec26dd3215..f189c367cf2 100644
--- a/homeassistant/components/conversation/__init__.py
+++ b/homeassistant/components/conversation/__init__.py
@@ -50,14 +50,13 @@ from .const import (
ATTR_LANGUAGE,
ATTR_TEXT,
DATA_COMPONENT,
- DATA_DEFAULT_ENTITY,
DOMAIN,
HOME_ASSISTANT_AGENT,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
)
-from .default_agent import DefaultAgent, async_setup_default_agent
+from .default_agent import async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
@@ -142,7 +141,7 @@ def async_unset_agent(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
- """Set the agent to handle the conversations."""
+ """Unset the agent to handle the conversations."""
get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
@@ -241,10 +240,10 @@ async def async_handle_sentence_triggers(
Returns None if no match occurred.
"""
- default_agent = async_get_agent(hass)
- assert isinstance(default_agent, DefaultAgent)
+ agent = get_agent_manager(hass).default_agent
+ assert agent is not None
- return await default_agent.async_handle_sentence_triggers(user_input)
+ return await agent.async_handle_sentence_triggers(user_input)
async def async_handle_intents(
@@ -257,12 +256,10 @@ async def async_handle_intents(
Returns None if no match occurred.
"""
- default_agent = async_get_agent(hass)
- assert isinstance(default_agent, DefaultAgent)
+ agent = get_agent_manager(hass).default_agent
+ assert agent is not None
- return await default_agent.async_handle_intents(
- user_input, intent_filter=intent_filter
- )
+ return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -298,9 +295,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
- await hass.data[DATA_DEFAULT_ENTITY].async_reload(
- language=service.data.get(ATTR_LANGUAGE)
- )
+ agent = get_agent_manager(hass).default_agent
+ if agent is not None:
+ await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
hass.services.async_register(
DOMAIN,
diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py
index 6203525ac01..bef6d933abe 100644
--- a/homeassistant/components/conversation/agent_manager.py
+++ b/homeassistant/components/conversation/agent_manager.py
@@ -4,15 +4,21 @@ from __future__ import annotations
import dataclasses
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
-from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ Context,
+ HomeAssistant,
+ async_get_hass,
+ callback,
+)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent, singleton
-from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
+from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
from .entity import ConversationEntity
from .models import (
AbstractConversationAgent,
@@ -28,6 +34,10 @@ from .trace import (
_LOGGER = logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from .default_agent import DefaultAgent
+ from .trigger import TriggerDetails
+
@singleton.singleton("conversation_agent")
@callback
@@ -49,8 +59,10 @@ def async_get_agent(
hass: HomeAssistant, agent_id: str | None = None
) -> AbstractConversationAgent | ConversationEntity | None:
"""Get specified agent."""
+ manager = get_agent_manager(hass)
+
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
- return hass.data[DATA_DEFAULT_ENTITY]
+ return manager.default_agent
if "." in agent_id:
return hass.data[DATA_COMPONENT].get_entity(agent_id)
@@ -71,6 +83,7 @@ async def async_converse(
language: str | None = None,
agent_id: str | None = None,
device_id: str | None = None,
+ satellite_id: str | None = None,
extra_system_prompt: str | None = None,
) -> ConversationResult:
"""Process text and get intent."""
@@ -97,6 +110,7 @@ async def async_converse(
context=context,
conversation_id=conversation_id,
device_id=device_id,
+ satellite_id=satellite_id,
language=language,
agent_id=agent_id,
extra_system_prompt=extra_system_prompt,
@@ -132,6 +146,8 @@ class AgentManager:
"""Initialize the conversation agents."""
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
+ self.default_agent: DefaultAgent | None = None
+ self.triggers_details: list[TriggerDetails] = []
@callback
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
@@ -180,3 +196,23 @@ class AgentManager:
def async_unset_agent(self, agent_id: str) -> None:
"""Unset the agent."""
self._agents.pop(agent_id, None)
+
+ async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
+ """Set up the default agent."""
+ agent.update_triggers(self.triggers_details)
+ self.default_agent = agent
+
+ def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
+ """Register a trigger."""
+ self.triggers_details.append(trigger_details)
+ if self.default_agent is not None:
+ self.default_agent.update_triggers(self.triggers_details)
+
+ @callback
+ def unregister_trigger() -> None:
+ """Unregister the trigger."""
+ self.triggers_details.remove(trigger_details)
+ if self.default_agent is not None:
+ self.default_agent.update_triggers(self.triggers_details)
+
+ return unregister_trigger
diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py
index 2f5e3b0cf82..56a0b46f52b 100644
--- a/homeassistant/components/conversation/chat_log.py
+++ b/homeassistant/components/conversation/chat_log.py
@@ -507,14 +507,18 @@ class ChatLog:
async def async_provide_llm_data(
self,
llm_context: llm.LLMContext,
- user_llm_hass_api: str | list[str] | None = None,
+ user_llm_hass_api: str | list[str] | llm.API | None = None,
user_llm_prompt: str | None = None,
user_extra_system_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_api: llm.APIInstance | None = None
- if user_llm_hass_api:
+ if user_llm_hass_api is None:
+ pass
+ elif isinstance(user_llm_hass_api, llm.API):
+ llm_api = await user_llm_hass_api.async_get_api_instance(llm_context)
+ else:
try:
llm_api = await llm.async_get_api(
self.hass,
diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py
index 266a9f15b83..e1029de9918 100644
--- a/homeassistant/components/conversation/const.py
+++ b/homeassistant/components/conversation/const.py
@@ -10,11 +10,9 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent
- from .default_agent import DefaultAgent
from .entity import ConversationEntity
DOMAIN = "conversation"
-DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
ATTR_TEXT = "text"
@@ -26,7 +24,6 @@ SERVICE_PROCESS = "process"
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
-DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
class ConversationEntityFeature(IntFlag):
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 4b056ead2c2..6c238ff0c52 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -4,13 +4,11 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict
-from collections.abc import Awaitable, Callable, Iterable
+from collections.abc import Callable, Iterable
from dataclasses import dataclass
from enum import Enum, auto
-import functools
import logging
from pathlib import Path
-import re
import time
from typing import IO, Any, cast
@@ -35,7 +33,7 @@ from hassil.recognize import (
)
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.trie import Trie
-from hassil.util import merge_dict
+from hassil.util import merge_dict, remove_punctuation
from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
@@ -53,6 +51,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
async_should_expose,
)
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
+from homeassistant.core import Event, callback
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
@@ -68,25 +67,22 @@ from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
+from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
-from .const import (
- DATA_DEFAULT_ENTITY,
- DEFAULT_EXPOSED_ATTRIBUTES,
- DOMAIN,
- ConversationEntityFeature,
-)
+from .const import DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
+from .trigger import TriggerDetails
_LOGGER = logging.getLogger(__name__)
+
+
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
-REGEX_TYPE = type(re.compile(""))
-TRIGGER_CALLBACK_TYPE = Callable[
- [ConversationInput, RecognizeResult], Awaitable[str | None]
-]
+_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
+
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
@@ -112,14 +108,6 @@ class LanguageIntents:
fuzzy_responses: FuzzyLanguageResponses | None = None
-@dataclass(slots=True)
-class TriggerData:
- """List of sentences and the callback for a trigger."""
-
- sentences: list[str]
- callback: TRIGGER_CALLBACK_TYPE
-
-
@dataclass(slots=True)
class SentenceTriggerResult:
"""Result when matching a sentence trigger in an automation."""
@@ -155,8 +143,8 @@ class IntentCacheKey:
language: str
"""Language of text."""
- device_id: str | None
- """Device id from user input."""
+ satellite_id: str | None
+ """Satellite id from user input."""
@dataclass(frozen=True)
@@ -209,9 +197,9 @@ async def async_setup_default_agent(
config_intents: dict[str, Any],
) -> None:
"""Set up entity registry listener for the default agent."""
- entity = DefaultAgent(hass, config_intents)
- await entity_component.async_add_entities([entity])
- hass.data[DATA_DEFAULT_ENTITY] = entity
+ agent = DefaultAgent(hass, config_intents)
+ await entity_component.async_add_entities([agent])
+ await get_agent_manager(hass).async_setup_default_agent(agent)
@core.callback
def async_entity_state_listener(
@@ -242,21 +230,23 @@ class DefaultAgent(ConversationEntity):
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
+ self._load_intents_lock = asyncio.Lock()
# intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents
+
+ # Sentences that will trigger a callback (skipping intent recognition)
+ self._triggers_details: list[TriggerDetails] = []
+ self._trigger_intents: Intents | None = None
+
+ # Slot lists for entities, areas, etc.
self._slot_lists: dict[str, SlotList] | None = None
+ self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
# Used to filter slot lists before intent matching
self._exposed_names_trie: Trie | None = None
self._unexposed_names_trie: Trie | None = None
- # Sentences that will trigger a callback (skipping intent recognition)
- self.trigger_sentences: list[TriggerData] = []
- self._trigger_intents: Intents | None = None
- self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
- self._load_intents_lock = asyncio.Lock()
-
# LRU cache to avoid unnecessary intent matching
self._intent_cache = IntentCache(capacity=128)
@@ -327,12 +317,10 @@ class DefaultAgent(ConversationEntity):
if self._exposed_names_trie is not None:
# Filter by input string
- text_lower = user_input.text.strip().lower()
+ text = remove_punctuation(user_input.text).strip().lower()
slot_lists["name"] = TextSlotList(
name="name",
- values=[
- result[2] for result in self._exposed_names_trie.find(text_lower)
- ],
+ values=[result[2] for result in self._exposed_names_trie.find(text)],
)
start = time.monotonic()
@@ -373,7 +361,6 @@ class DefaultAgent(ConversationEntity):
response = intent.IntentResponse(
language=user_input.language or self.hass.config.language
)
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_speech(response_text)
if response is None:
@@ -448,9 +435,15 @@ class DefaultAgent(ConversationEntity):
}
for entity in result.entities_list
}
- device_area = self._get_device_area(user_input.device_id)
- if device_area:
- slots["preferred_area_id"] = {"value": device_area.id}
+
+ satellite_id = user_input.satellite_id
+ device_id = user_input.device_id
+ satellite_area, device_id = self._get_satellite_area_and_device(
+ satellite_id, device_id
+ )
+ if satellite_area is not None:
+ slots["preferred_area_id"] = {"value": satellite_area.id}
+
async_conversation_trace_append(
ConversationTraceEventType.TOOL_CALL,
{
@@ -472,7 +465,8 @@ class DefaultAgent(ConversationEntity):
user_input.context,
language,
assistant=DOMAIN,
- device_id=user_input.device_id,
+ device_id=device_id,
+ satellite_id=satellite_id,
conversation_agent_id=user_input.agent_id,
)
except intent.MatchFailedError as match_error:
@@ -538,7 +532,9 @@ class DefaultAgent(ConversationEntity):
# Try cache first
cache_key = IntentCacheKey(
- text=user_input.text, language=language, device_id=user_input.device_id
+ text=user_input.text,
+ language=language,
+ satellite_id=user_input.satellite_id,
)
cache_value = self._intent_cache.get(cache_key)
if cache_value is not None:
@@ -848,7 +844,7 @@ class DefaultAgent(ConversationEntity):
context = {"domain": state.domain}
if state.attributes:
# Include some attributes
- for attr in DEFAULT_EXPOSED_ATTRIBUTES:
+ for attr in _DEFAULT_EXPOSED_ATTRIBUTES:
if attr not in state.attributes:
continue
context[attr] = state.attributes[attr]
@@ -1194,8 +1190,8 @@ class DefaultAgent(ConversationEntity):
fuzzy_responses=fuzzy_responses,
)
- @core.callback
- def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
+ @callback
+ def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None:
"""Clear slot lists when a registry has changed."""
# Two subscribers can be scheduled at same time
_LOGGER.debug("Clearing slot lists")
@@ -1263,7 +1259,7 @@ class DefaultAgent(ConversationEntity):
name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
for name_value in name_list.values:
assert isinstance(name_value.text_in, TextChunk)
- name_text = name_value.text_in.text.strip().lower()
+ name_text = remove_punctuation(name_value.text_in.text).strip().lower()
self._exposed_names_trie.insert(name_text, name_value)
self._slot_lists = {
@@ -1308,28 +1304,40 @@ class DefaultAgent(ConversationEntity):
self, user_input: ConversationInput
) -> dict[str, Any] | None:
"""Return intent recognition context for user input."""
- if not user_input.device_id:
+ satellite_area, _ = self._get_satellite_area_and_device(
+ user_input.satellite_id, user_input.device_id
+ )
+ if satellite_area is None:
return None
- device_area = self._get_device_area(user_input.device_id)
- if device_area is None:
- return None
+ return {"area": {"value": satellite_area.name, "text": satellite_area.name}}
- return {"area": {"value": device_area.name, "text": device_area.name}}
+ def _get_satellite_area_and_device(
+ self, satellite_id: str | None, device_id: str | None = None
+ ) -> tuple[ar.AreaEntry | None, str | None]:
+ """Return area entry and device id."""
+ hass = self.hass
- def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
- """Return area object for given device identifier."""
- if device_id is None:
- return None
+ area_id: str | None = None
- devices = dr.async_get(self.hass)
- device = devices.async_get(device_id)
- if (device is None) or (device.area_id is None):
- return None
+ if (
+ satellite_id is not None
+ and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None
+ ):
+ area_id = entity_entry.area_id
+ device_id = entity_entry.device_id
- areas = ar.async_get(self.hass)
+ if (
+ area_id is None
+ and device_id is not None
+ and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
+ ):
+ area_id = device_entry.area_id
- return areas.async_get_area(device.area_id)
+ if area_id is None:
+ return None, device_id
+
+ return ar.async_get(hass).async_get_area(area_id), device_id
def _get_error_text(
self,
@@ -1353,22 +1361,14 @@ class DefaultAgent(ConversationEntity):
return response_template.async_render(response_args)
- @core.callback
- def register_trigger(
- self,
- sentences: list[str],
- callback: TRIGGER_CALLBACK_TYPE,
- ) -> core.CALLBACK_TYPE:
- """Register a list of sentences that will trigger a callback when recognized."""
- trigger_data = TriggerData(sentences=sentences, callback=callback)
- self.trigger_sentences.append(trigger_data)
+ @callback
+ def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
+ """Update triggers."""
+ self._triggers_details = triggers_details
# Force rebuild on next use
self._trigger_intents = None
- return functools.partial(self._unregister_trigger, trigger_data)
-
- @core.callback
def _rebuild_trigger_intents(self) -> None:
"""Rebuild the HassIL intents object from the current trigger sentences."""
intents_dict = {
@@ -1377,8 +1377,8 @@ class DefaultAgent(ConversationEntity):
# Use trigger data index as a virtual intent name for HassIL.
# This works because the intents are rebuilt on every
# register/unregister.
- str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
- for trigger_id, trigger_data in enumerate(self.trigger_sentences)
+ str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
+ for trigger_id, trigger_details in enumerate(self._triggers_details)
},
}
@@ -1398,14 +1398,6 @@ class DefaultAgent(ConversationEntity):
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
- @core.callback
- def _unregister_trigger(self, trigger_data: TriggerData) -> None:
- """Unregister a set of trigger sentences."""
- self.trigger_sentences.remove(trigger_data)
-
- # Force rebuild on next use
- self._trigger_intents = None
-
async def async_recognize_sentence_trigger(
self, user_input: ConversationInput
) -> SentenceTriggerResult | None:
@@ -1414,7 +1406,7 @@ class DefaultAgent(ConversationEntity):
Calls the registered callbacks if there's a match and returns a sentence
trigger result.
"""
- if not self.trigger_sentences:
+ if not self._triggers_details:
# No triggers registered
return None
@@ -1459,7 +1451,7 @@ class DefaultAgent(ConversationEntity):
# Gather callback responses in parallel
trigger_callbacks = [
- self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
+ self._triggers_details[trigger_id].callback(user_input, trigger_result)
for trigger_id, trigger_result in result.matched_triggers.items()
]
diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py
index 290e3aab955..c43e6709855 100644
--- a/homeassistant/components/conversation/http.py
+++ b/homeassistant/components/conversation/http.py
@@ -25,7 +25,7 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
-from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
+from .const import DATA_COMPONENT
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
@@ -169,11 +169,11 @@ async def websocket_list_sentences(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""List custom registered sentences."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
+ manager = get_agent_manager(hass)
sentences = []
- for trigger_data in agent.trigger_sentences:
- sentences.extend(trigger_data.sentences)
+ for trigger_details in manager.triggers_details:
+ sentences.extend(trigger_details.sentences)
connection.send_result(msg["id"], {"trigger_sentences": sentences})
@@ -191,7 +191,8 @@ async def websocket_hass_agent_debug(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return intents that would be matched by the default agent for a list of sentences."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = get_agent_manager(hass).default_agent
+ assert agent is not None
# Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = []
@@ -201,6 +202,7 @@ async def websocket_hass_agent_debug(
context=connection.context(msg),
conversation_id=None,
device_id=msg.get("device_id"),
+ satellite_id=None,
language=msg.get("language", hass.config.language),
agent_id=agent.entity_id,
)
diff --git a/homeassistant/components/conversation/icons.json b/homeassistant/components/conversation/icons.json
index 658783f9ae2..55bacf838a8 100644
--- a/homeassistant/components/conversation/icons.json
+++ b/homeassistant/components/conversation/icons.json
@@ -1,4 +1,9 @@
{
+ "entity_component": {
+ "_": {
+ "default": "mdi:forum-outline"
+ }
+ },
"services": {
"process": {
"service": "mdi:message-processing"
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index e7d096212ba..040f6c3a863 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "conversation",
"name": "Conversation",
- "codeowners": ["@home-assistant/core", "@synesthesiam"],
+ "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["http", "intent"],
"documentation": "https://www.home-assistant.io/integrations/conversation",
- "integration_type": "system",
+ "integration_type": "entity",
"quality_scale": "internal",
- "requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"]
+ "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"]
}
diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py
index dac1fb862ec..96c245d4b27 100644
--- a/homeassistant/components/conversation/models.py
+++ b/homeassistant/components/conversation/models.py
@@ -37,6 +37,9 @@ class ConversationInput:
device_id: str | None
"""Unique identifier for the device."""
+ satellite_id: str | None
+ """Unique identifier for the satellite."""
+
language: str
"""Language of the request."""
@@ -53,6 +56,7 @@ class ConversationInput:
"context": self.context.as_dict(),
"conversation_id": self.conversation_id,
"device_id": self.device_id,
+ "satellite_id": self.satellite_id,
"language": self.language,
"agent_id": self.agent_id,
"extra_system_prompt": self.extra_system_prompt,
diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py
index 752e294a8b3..8f151825071 100644
--- a/homeassistant/components/conversation/trigger.py
+++ b/homeassistant/components/conversation/trigger.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
from typing import Any
from hassil.recognize import RecognizeResult
@@ -15,14 +17,27 @@ import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.script import ScriptRunResult
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import UNDEFINED, ConfigType
-from .const import DATA_DEFAULT_ENTITY, DOMAIN
+from .agent_manager import get_agent_manager
+from .const import DOMAIN
from .models import ConversationInput
+TRIGGER_CALLBACK_TYPE = Callable[
+ [ConversationInput, RecognizeResult], Awaitable[str | None]
+]
+
+
+@dataclass(slots=True)
+class TriggerDetails:
+ """List of sentences and the callback for a trigger."""
+
+ sentences: list[str]
+ callback: TRIGGER_CALLBACK_TYPE
+
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
@@ -70,6 +85,8 @@ async def async_attach_trigger(
trigger_data = trigger_info["trigger_data"]
sentences = config.get(CONF_COMMAND, [])
+ ent_reg = er.async_get(hass)
+
job = HassJob(action)
async def call_action(
@@ -91,6 +108,14 @@ async def async_attach_trigger(
for entity_name, entity in result.entities.items()
}
+ satellite_id = user_input.satellite_id
+ device_id = user_input.device_id
+ if (
+ satellite_id is not None
+ and (satellite_entry := ent_reg.async_get(satellite_id)) is not None
+ ):
+ device_id = satellite_entry.device_id
+
trigger_input: dict[str, Any] = { # Satisfy type checker
**trigger_data,
"platform": DOMAIN,
@@ -99,7 +124,8 @@ async def async_attach_trigger(
"slots": { # direct access to values
entity_name: entity["value"] for entity_name, entity in details.items()
},
- "device_id": user_input.device_id,
+ "device_id": device_id,
+ "satellite_id": satellite_id,
"user_input": user_input.as_dict(),
}
@@ -122,4 +148,6 @@ async def async_attach_trigger(
# two trigger copies for who will provide a response.
return None
- return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action)
+ return get_agent_manager(hass).register_trigger(
+ TriggerDetails(sentences=sentences, callback=call_action)
+ )
diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py
new file mode 100644
index 00000000000..a2fa7ad509a
--- /dev/null
+++ b/homeassistant/components/cync/__init__.py
@@ -0,0 +1,58 @@
+"""The Cync integration."""
+
+from __future__ import annotations
+
+from pycync import Auth, Cync, User
+from pycync.exceptions import AuthFailedError, CyncError
+
+from homeassistant.const import CONF_ACCESS_TOKEN, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ CONF_AUTHORIZE_STRING,
+ CONF_EXPIRES_AT,
+ CONF_REFRESH_TOKEN,
+ CONF_USER_ID,
+)
+from .coordinator import CyncConfigEntry, CyncCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.LIGHT]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
+ """Set up Cync from a config entry."""
+ user_info = User(
+ entry.data[CONF_ACCESS_TOKEN],
+ entry.data[CONF_REFRESH_TOKEN],
+ entry.data[CONF_AUTHORIZE_STRING],
+ entry.data[CONF_USER_ID],
+ expires_at=entry.data[CONF_EXPIRES_AT],
+ )
+ cync_auth = Auth(async_get_clientsession(hass), user=user_info)
+
+ try:
+ cync = await Cync.create(cync_auth)
+ except AuthFailedError as ex:
+ raise ConfigEntryAuthFailed("User token invalid") from ex
+ except CyncError as ex:
+ raise ConfigEntryNotReady("Unable to connect to Cync") from ex
+
+ devices_coordinator = CyncCoordinator(hass, entry, cync)
+
+ cync.set_update_callback(devices_coordinator.on_data_update)
+
+ await devices_coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = devices_coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
+ """Unload a config entry."""
+ cync = entry.runtime_data.cync
+ await cync.shut_down()
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/cync/config_flow.py b/homeassistant/components/cync/config_flow.py
new file mode 100644
index 00000000000..b10f1c03cc3
--- /dev/null
+++ b/homeassistant/components/cync/config_flow.py
@@ -0,0 +1,118 @@
+"""Config flow for the Cync integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from pycync import Auth
+from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ CONF_AUTHORIZE_STRING,
+ CONF_EXPIRES_AT,
+ CONF_REFRESH_TOKEN,
+ CONF_TWO_FACTOR_CODE,
+ CONF_USER_ID,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str})
+
+
+class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Cync."""
+
+ VERSION = 1
+
+ cync_auth: Auth
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Attempt login with user credentials."""
+ errors: dict[str, str] = {}
+
+ if user_input is None:
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ self.cync_auth = Auth(
+ async_get_clientsession(self.hass),
+ username=user_input[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ )
+ try:
+ await self.cync_auth.login()
+ except AuthFailedError:
+ errors["base"] = "invalid_auth"
+ except TwoFactorRequiredError:
+ return await self.async_step_two_factor()
+ except CyncError:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return await self._create_config_entry(self.cync_auth.username)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_two_factor(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Attempt login with the two factor auth code sent to the user."""
+ errors: dict[str, str] = {}
+
+ if user_input is None:
+ return self.async_show_form(
+ step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
+ )
+ try:
+ await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
+ except AuthFailedError:
+ errors["base"] = "invalid_auth"
+ except CyncError:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return await self._create_config_entry(self.cync_auth.username)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
+ """Create the Cync config entry using input user data."""
+
+ cync_user = self.cync_auth.user
+ await self.async_set_unique_id(str(cync_user.user_id))
+ self._abort_if_unique_id_configured()
+
+ config = {
+ CONF_USER_ID: cync_user.user_id,
+ CONF_AUTHORIZE_STRING: cync_user.authorize,
+ CONF_EXPIRES_AT: cync_user.expires_at,
+ CONF_ACCESS_TOKEN: cync_user.access_token,
+ CONF_REFRESH_TOKEN: cync_user.refresh_token,
+ }
+ return self.async_create_entry(title=user_email, data=config)
diff --git a/homeassistant/components/cync/const.py b/homeassistant/components/cync/const.py
new file mode 100644
index 00000000000..410863b624d
--- /dev/null
+++ b/homeassistant/components/cync/const.py
@@ -0,0 +1,9 @@
+"""Constants for the Cync integration."""
+
+DOMAIN = "cync"
+
+CONF_TWO_FACTOR_CODE = "two_factor_code"
+CONF_USER_ID = "user_id"
+CONF_AUTHORIZE_STRING = "authorize_string"
+CONF_EXPIRES_AT = "expires_at"
+CONF_REFRESH_TOKEN = "refresh_token"
diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py
new file mode 100644
index 00000000000..84bfa6d0fee
--- /dev/null
+++ b/homeassistant/components/cync/coordinator.py
@@ -0,0 +1,87 @@
+"""Coordinator to handle keeping device states up to date."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+import time
+
+from pycync import Cync, CyncDevice, User
+from pycync.exceptions import AuthFailedError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN
+
+_LOGGER = logging.getLogger(__name__)
+
+type CyncConfigEntry = ConfigEntry[CyncCoordinator]
+
+
+class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]):
+ """Coordinator to handle updating Cync device states."""
+
+ config_entry: CyncConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync
+ ) -> None:
+ """Initialize the Cync coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Cync Data Coordinator",
+ config_entry=config_entry,
+ update_interval=timedelta(seconds=30),
+ always_update=True,
+ )
+ self.cync = cync
+
+ async def on_data_update(self, data: dict[int, CyncDevice]) -> None:
+ """Update registered devices with new data."""
+ merged_data = self.data | data if self.data else data
+ self.async_set_updated_data(merged_data)
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator with initial device states."""
+ logged_in_user = self.cync.get_logged_in_user()
+ if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]:
+ await self._update_config_cync_credentials(logged_in_user)
+
+ async def _async_update_data(self) -> dict[int, CyncDevice]:
+ """First, refresh the user's auth token if it is set to expire in less than one hour.
+
+ Then, fetch all current device states.
+ """
+
+ logged_in_user = self.cync.get_logged_in_user()
+ if logged_in_user.expires_at - time.time() < 3600:
+ await self._async_refresh_cync_credentials()
+
+ self.cync.update_device_states()
+ current_device_states = self.cync.get_devices()
+
+ return {device.device_id: device for device in current_device_states}
+
+ async def _async_refresh_cync_credentials(self) -> None:
+ """Attempt to refresh the Cync user's authentication token."""
+
+ try:
+ refreshed_user = await self.cync.refresh_credentials()
+ except AuthFailedError as ex:
+ raise ConfigEntryAuthFailed("Unable to refresh user token") from ex
+ else:
+ await self._update_config_cync_credentials(refreshed_user)
+
+ async def _update_config_cync_credentials(self, user_info: User) -> None:
+ """Update the config entry with current user info."""
+
+ new_data = {**self.config_entry.data}
+ new_data[CONF_ACCESS_TOKEN] = user_info.access_token
+ new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token
+ new_data[CONF_EXPIRES_AT] = user_info.expires_at
+ self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
diff --git a/homeassistant/components/cync/entity.py b/homeassistant/components/cync/entity.py
new file mode 100644
index 00000000000..c2946615e1c
--- /dev/null
+++ b/homeassistant/components/cync/entity.py
@@ -0,0 +1,45 @@
+"""Setup for a generic entity type for the Cync integration."""
+
+from pycync.devices import CyncDevice
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import CyncCoordinator
+
+
+class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]):
+ """Generic base entity for Cync devices."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ device: CyncDevice,
+ coordinator: CyncCoordinator,
+ room_name: str | None = None,
+ ) -> None:
+ """Pass coordinator to CoordinatorEntity."""
+ super().__init__(coordinator)
+
+ self._cync_device_id = device.device_id
+ self._attr_unique_id = device.unique_id
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.unique_id)},
+ manufacturer="GE Lighting",
+ name=device.name,
+ suggested_area=room_name,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Determines whether this device is currently available."""
+
+ return (
+ super().available
+ and self.coordinator.data is not None
+ and self._cync_device_id in self.coordinator.data
+ and self.coordinator.data[self._cync_device_id].is_online
+ )
diff --git a/homeassistant/components/cync/light.py b/homeassistant/components/cync/light.py
new file mode 100644
index 00000000000..8604beab417
--- /dev/null
+++ b/homeassistant/components/cync/light.py
@@ -0,0 +1,180 @@
+"""Support for Cync light entities."""
+
+from typing import Any
+
+from pycync import CyncLight
+from pycync.devices.capabilities import CyncCapability
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP_KELVIN,
+ ATTR_RGB_COLOR,
+ ColorMode,
+ LightEntity,
+ filter_supported_color_modes,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util.color import value_to_brightness
+from homeassistant.util.scaling import scale_ranged_value_to_int_range
+
+from .coordinator import CyncConfigEntry, CyncCoordinator
+from .entity import CyncBaseEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: CyncConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Cync lights from a config entry."""
+
+ coordinator = entry.runtime_data
+ cync = coordinator.cync
+
+ entities_to_add = []
+
+ for home in cync.get_homes():
+ for room in home.rooms:
+ room_lights = [
+ CyncLightEntity(device, coordinator, room.name)
+ for device in room.devices
+ if isinstance(device, CyncLight)
+ ]
+ entities_to_add.extend(room_lights)
+
+ group_lights = [
+ CyncLightEntity(device, coordinator, room.name)
+ for group in room.groups
+ for device in group.devices
+ if isinstance(device, CyncLight)
+ ]
+ entities_to_add.extend(group_lights)
+
+ async_add_entities(entities_to_add)
+
+
+class CyncLightEntity(CyncBaseEntity, LightEntity):
+ """Representation of a Cync light."""
+
+ _attr_color_mode = ColorMode.ONOFF
+ _attr_min_color_temp_kelvin = 2000
+ _attr_max_color_temp_kelvin = 7000
+ _attr_translation_key = "light"
+ _attr_name = None
+
+ BRIGHTNESS_SCALE = (0, 100)
+
+ def __init__(
+ self,
+ device: CyncLight,
+ coordinator: CyncCoordinator,
+ room_name: str | None = None,
+ ) -> None:
+ """Set up base attributes."""
+ super().__init__(device, coordinator, room_name)
+
+ supported_color_modes = {ColorMode.ONOFF}
+ if device.supports_capability(CyncCapability.CCT_COLOR):
+ supported_color_modes.add(ColorMode.COLOR_TEMP)
+ if device.supports_capability(CyncCapability.DIMMING):
+ supported_color_modes.add(ColorMode.BRIGHTNESS)
+ if device.supports_capability(CyncCapability.RGB_COLOR):
+ supported_color_modes.add(ColorMode.RGB)
+ self._attr_supported_color_modes = filter_supported_color_modes(
+ supported_color_modes
+ )
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return True if the light is on."""
+ return self._device.is_on
+
+ @property
+ def brightness(self) -> int:
+ """Provide the light's current brightness."""
+ return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
+
+ @property
+ def color_temp_kelvin(self) -> int:
+ """Return color temperature in kelvin."""
+ return scale_ranged_value_to_int_range(
+ (1, 100),
+ (self.min_color_temp_kelvin, self.max_color_temp_kelvin),
+ self._device.color_temp,
+ )
+
+ @property
+ def rgb_color(self) -> tuple[int, int, int]:
+ """Provide the light's current color in RGB format."""
+ return self._device.rgb
+
+ @property
+ def color_mode(self) -> str | None:
+ """Return the active color mode."""
+
+ if (
+ self._device.supports_capability(CyncCapability.CCT_COLOR)
+ and self._device.color_mode > 0
+ and self._device.color_mode <= 100
+ ):
+ return ColorMode.COLOR_TEMP
+ if (
+ self._device.supports_capability(CyncCapability.RGB_COLOR)
+ and self._device.color_mode == 254
+ ):
+ return ColorMode.RGB
+ if self._device.supports_capability(CyncCapability.DIMMING):
+ return ColorMode.BRIGHTNESS
+
+ return ColorMode.ONOFF
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Process an action on the light."""
+ if not kwargs:
+ await self._device.turn_on()
+
+ elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
+ color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
+ converted_color_temp = self._normalize_color_temp(color_temp)
+
+ await self._device.set_color_temp(converted_color_temp)
+ elif kwargs.get(ATTR_RGB_COLOR) is not None:
+ rgb = kwargs.get(ATTR_RGB_COLOR)
+
+ await self._device.set_rgb(rgb)
+ elif kwargs.get(ATTR_BRIGHTNESS) is not None:
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ converted_brightness = self._normalize_brightness(brightness)
+
+ await self._device.set_brightness(converted_brightness)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the light."""
+ await self._device.turn_off()
+
+ def _normalize_brightness(self, brightness: float | None) -> int | None:
+ """Return calculated brightness value scaled between 0-100."""
+ if brightness is not None:
+ return int((brightness / 255) * 100)
+
+ return None
+
+ def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None:
+ """Return calculated color temp value scaled between 1-100."""
+ if color_temp_kelvin is not None:
+ kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin
+ scaled_kelvin = int(
+ ((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100
+ )
+ if scaled_kelvin == 0:
+ scaled_kelvin += 1
+
+ return scaled_kelvin
+ return None
+
+ @property
+ def _device(self) -> CyncLight:
+ """Fetch the reference to the backing Cync light for this device."""
+
+ return self.coordinator.data[self._cync_device_id]
diff --git a/homeassistant/components/cync/manifest.json b/homeassistant/components/cync/manifest.json
new file mode 100644
index 00000000000..b61a3165a1d
--- /dev/null
+++ b/homeassistant/components/cync/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "cync",
+ "name": "Cync",
+ "codeowners": ["@Kinachi249"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/cync",
+ "integration_type": "hub",
+ "iot_class": "cloud_push",
+ "quality_scale": "bronze",
+ "requirements": ["pycync==0.4.1"]
+}
diff --git a/homeassistant/components/cync/quality_scale.yaml b/homeassistant/components/cync/quality_scale.yaml
new file mode 100644
index 00000000000..7e106cdd49e
--- /dev/null
+++ b/homeassistant/components/cync/quality_scale.yaml
@@ -0,0 +1,69 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/cync/strings.json b/homeassistant/components/cync/strings.json
new file mode 100644
index 00000000000..0515c053cfc
--- /dev/null
+++ b/homeassistant/components/cync/strings.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "Your Cync account's email address",
+ "password": "Your Cync account's password"
+ }
+ },
+ "two_factor": {
+ "data": {
+ "two_factor_code": "Two-factor code"
+ },
+ "data_description": {
+ "two_factor_code": "The two-factor code sent to your Cync account's email"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ }
+ }
+}
diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py
index 88a7b71e3ed..a96918747a2 100644
--- a/homeassistant/components/daikin/__init__.py
+++ b/homeassistant/components/daikin/__init__.py
@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.ssl import client_context_no_verify
-from .const import KEY_MAC, TIMEOUT
+from .const import KEY_MAC, TIMEOUT_SEC
from .coordinator import DaikinConfigEntry, DaikinCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
session = async_get_clientsession(hass)
host = conf[CONF_HOST]
try:
- async with asyncio.timeout(TIMEOUT):
+ async with asyncio.timeout(TIMEOUT_SEC):
device: Appliance = await DaikinFactory(
host,
session,
@@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
)
_LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err:
- _LOGGER.debug("Connection to %s timed out in 60 seconds", host)
+ _LOGGER.debug("Connection to %s timed out in %s seconds", host, TIMEOUT_SEC)
raise ConfigEntryNotReady from err
except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host)
diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py
index f5febafc4dc..85ed0804c66 100644
--- a/homeassistant/components/daikin/config_flow.py
+++ b/homeassistant/components/daikin/config_flow.py
@@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import client_context_no_verify
-from .const import DOMAIN, KEY_MAC, TIMEOUT
+from .const import DOMAIN, KEY_MAC, TIMEOUT_SEC
_LOGGER = logging.getLogger(__name__)
@@ -84,7 +84,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
password = None
try:
- async with asyncio.timeout(TIMEOUT):
+ async with asyncio.timeout(TIMEOUT_SEC):
device: Appliance = await DaikinFactory(
host,
async_get_clientsession(self.hass),
diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py
index 690267e5c83..f093569ea54 100644
--- a/homeassistant/components/daikin/const.py
+++ b/homeassistant/components/daikin/const.py
@@ -24,4 +24,4 @@ ATTR_STATE_OFF = "off"
KEY_MAC = "mac"
KEY_IP = "ip"
-TIMEOUT = 60
+TIMEOUT_SEC = 120
diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py
index 8e1713af5b2..9bd8d17bf48 100644
--- a/homeassistant/components/daikin/coordinator.py
+++ b/homeassistant/components/daikin/coordinator.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN
+from .const import DOMAIN, TIMEOUT_SEC
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
_LOGGER,
config_entry=entry,
name=device.values.get("name", DOMAIN),
- update_interval=timedelta(seconds=60),
+ update_interval=timedelta(seconds=TIMEOUT_SEC),
)
self.device = device
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index 799ff378a35..54f974e60a5 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
- "requirements": ["pydaikin==2.16.0"],
+ "requirements": ["pydaikin==2.17.1"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json
index 0b9f8ea55f5..8b52ae9aa02 100644
--- a/homeassistant/components/debugpy/manifest.json
+++ b/homeassistant/components/debugpy/manifest.json
@@ -6,5 +6,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["debugpy==1.8.14"]
+ "requirements": ["debugpy==1.8.16"]
}
diff --git a/homeassistant/components/deconz/entity.py b/homeassistant/components/deconz/entity.py
index fef973d612c..0d9247bedac 100644
--- a/homeassistant/components/deconz/entity.py
+++ b/homeassistant/components/deconz/entity.py
@@ -177,7 +177,7 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]):
"""Return a device description for device registry."""
return DeviceInfo(
identifiers={(DOMAIN, self._group_identifier)},
- manufacturer="Dresden Elektronik",
+ manufacturer="dresden elektronik",
model="deCONZ group",
name=self.group.name,
via_device=(DOMAIN, self.hub.api.config.bridge_id),
diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py
index f82f1d857fd..3fb864e7019 100644
--- a/homeassistant/components/deconz/hub/hub.py
+++ b/homeassistant/components/deconz/hub/hub.py
@@ -14,7 +14,6 @@ from pydeconz.models.event import EventType
from homeassistant.config_entries import SOURCE_HASSIO
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
from ..const import CONF_MASTER_GATEWAY, DOMAIN, HASSIO_CONFIGURATION_URL, PLATFORMS
@@ -169,17 +168,8 @@ class DeconzHub:
async def async_update_device_registry(self) -> None:
"""Update device registry."""
- if self.api.config.mac is None:
- return
-
device_registry = dr.async_get(self.hass)
- # Host device
- device_registry.async_get_or_create(
- config_entry_id=self.config_entry.entry_id,
- connections={(CONNECTION_NETWORK_MAC, self.api.config.mac)},
- )
-
# Gateway service
configuration_url = f"http://{self.config.host}:{self.config.port}"
if self.config_entry.source == SOURCE_HASSIO:
@@ -189,11 +179,10 @@ class DeconzHub:
configuration_url=configuration_url,
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self.api.config.bridge_id)},
- manufacturer="Dresden Elektronik",
+ manufacturer="dresden elektronik",
model=self.api.config.model_id,
name=self.api.config.name,
sw_version=self.api.config.software_version,
- via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac),
)
@staticmethod
diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py
index 1eb827f85d6..9b74008d426 100644
--- a/homeassistant/components/deconz/light.py
+++ b/homeassistant/components/deconz/light.py
@@ -396,7 +396,7 @@ class DeconzGroup(DeconzBaseLight[Group]):
"""Return a device description for device registry."""
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
- manufacturer="Dresden Elektronik",
+ manufacturer="dresden elektronik",
model="deCONZ group",
name=self._device.name,
via_device=(DOMAIN, self.hub.api.config.bridge_id),
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
index d318db6e2bf..955ea3df853 100644
--- a/homeassistant/components/deconz/sensor.py
+++ b/homeassistant/components/deconz/sensor.py
@@ -99,7 +99,7 @@ T = TypeVar(
@dataclass(frozen=True, kw_only=True)
-class DeconzSensorDescription(Generic[T], SensorEntityDescription):
+class DeconzSensorDescription(SensorEntityDescription, Generic[T]):
"""Class describing deCONZ binary sensor entities."""
instance_check: type[T] | None = None
diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py
index 1f032f3866a..b3c900c07c4 100644
--- a/homeassistant/components/deconz/services.py
+++ b/homeassistant/components/deconz/services.py
@@ -11,7 +11,6 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.read_only_dict import ReadOnlyDict
from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
@@ -120,8 +119,8 @@ async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None:
"field": "/lights/1/state",
"data": {"on": true}
}
- See Dresden Elektroniks REST API documentation for details:
- http://dresden-elektronik.github.io/deconz-rest-doc/rest/
+ See deCONZ REST-API documentation for details:
+ https://dresden-elektronik.github.io/deconz-rest-doc/
"""
field = data.get(SERVICE_FIELD, "")
entity_id = data.get(SERVICE_ENTITY)
@@ -162,14 +161,6 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None:
)
]
- # Don't remove the Gateway host entry
- if hub.api.config.mac:
- hub_host = device_registry.async_get_device(
- connections={(CONNECTION_NETWORK_MAC, hub.api.config.mac)},
- )
- if hub_host and hub_host.id in devices_to_be_removed:
- devices_to_be_removed.remove(hub_host.id)
-
# Don't remove the Gateway service entry
hub_service = device_registry.async_get_device(
identifiers={(DOMAIN, hub.api.config.bridge_id)}
diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json
index 8299fe43f09..7aa037ac047 100644
--- a/homeassistant/components/default_config/manifest.json
+++ b/homeassistant/components/default_config/manifest.json
@@ -9,6 +9,7 @@
"conversation",
"dhcp",
"energy",
+ "file",
"go2rtc",
"history",
"homeassistant_alerts",
@@ -19,6 +20,7 @@
"ssdp",
"stream",
"sun",
+ "usage_prediction",
"usb",
"webhook",
"zeroconf"
diff --git a/homeassistant/components/deluge/const.py b/homeassistant/components/deluge/const.py
index a76817519da..909fa2e98c3 100644
--- a/homeassistant/components/deluge/const.py
+++ b/homeassistant/components/deluge/const.py
@@ -43,3 +43,5 @@ class DelugeSensorType(enum.StrEnum):
UPLOAD_SPEED_SENSOR = "upload_speed"
PROTOCOL_TRAFFIC_UPLOAD_SPEED_SENSOR = "protocol_traffic_upload_speed"
PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR = "protocol_traffic_download_speed"
+ DOWNLOADING_COUNT_SENSOR = "downloading_count"
+ SEEDING_COUNT_SENSOR = "seeding_count"
diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py
index c5836243b9d..f86f92767ee 100644
--- a/homeassistant/components/deluge/coordinator.py
+++ b/homeassistant/components/deluge/coordinator.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections import Counter
from datetime import timedelta
from ssl import SSLError
from typing import Any
@@ -14,11 +15,22 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import LOGGER, DelugeGetSessionStatusKeys
+from .const import LOGGER, DelugeGetSessionStatusKeys, DelugeSensorType
type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator]
+def count_states(data: dict[str, Any]) -> dict[str, int]:
+ """Count the states of the provided torrents."""
+
+ counts = Counter(torrent[b"state"].decode() for torrent in data.values())
+
+ return {
+ DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value: counts.get("Downloading", 0),
+ DelugeSensorType.SEEDING_COUNT_SENSOR.value: counts.get("Seeding", 0),
+ }
+
+
class DelugeDataUpdateCoordinator(
DataUpdateCoordinator[dict[Platform, dict[str, Any]]]
):
@@ -39,19 +51,22 @@ class DelugeDataUpdateCoordinator(
)
self.api = api
- async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
- """Get the latest data from Deluge and updates the state."""
+ def _get_deluge_data(self):
+ """Get the latest data from Deluge."""
+
data = {}
try:
- _data = await self.hass.async_add_executor_job(
- self.api.call,
+ data["session_status"] = self.api.call(
"core.get_session_status",
[iter_member.value for iter_member in list(DelugeGetSessionStatusKeys)],
)
- data[Platform.SENSOR] = {k.decode(): v for k, v in _data.items()}
- data[Platform.SWITCH] = await self.hass.async_add_executor_job(
- self.api.call, "core.get_torrents_status", {}, ["paused"]
+ data["torrents_status_state"] = self.api.call(
+ "core.get_torrents_status", {}, ["state"]
)
+ data["torrents_status_paused"] = self.api.call(
+ "core.get_torrents_status", {}, ["paused"]
+ )
+
except (
ConnectionRefusedError,
TimeoutError,
@@ -66,4 +81,18 @@ class DelugeDataUpdateCoordinator(
) from ex
LOGGER.error("Unknown error connecting to Deluge: %s", ex)
raise
+
+ return data
+
+ async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
+ """Get the latest data from Deluge and updates the state."""
+
+ deluge_data = await self.hass.async_add_executor_job(self._get_deluge_data)
+
+ data = {}
+ data[Platform.SENSOR] = {
+ k.decode(): v for k, v in deluge_data["session_status"].items()
+ }
+ data[Platform.SENSOR].update(count_states(deluge_data["torrents_status_state"]))
+ data[Platform.SWITCH] = deluge_data["torrents_status_paused"]
return data
diff --git a/homeassistant/components/deluge/icons.json b/homeassistant/components/deluge/icons.json
new file mode 100644
index 00000000000..67805322cdb
--- /dev/null
+++ b/homeassistant/components/deluge/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "downloading_count": {
+ "default": "mdi:download"
+ },
+ "seeding_count": {
+ "default": "mdi:upload"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py
index d6809967703..eb6ac9b27b9 100644
--- a/homeassistant/components/deluge/sensor.py
+++ b/homeassistant/components/deluge/sensor.py
@@ -110,6 +110,18 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
data, DelugeSensorType.PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR.value
),
),
+ DelugeSensorEntityDescription(
+ key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
+ translation_key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
+ state_class=SensorStateClass.TOTAL,
+ value=lambda data: data[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value],
+ ),
+ DelugeSensorEntityDescription(
+ key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
+ translation_key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
+ state_class=SensorStateClass.TOTAL,
+ value=lambda data: data[DelugeSensorType.SEEDING_COUNT_SENSOR.value],
+ ),
)
diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json
index ddea78b315f..be412b71081 100644
--- a/homeassistant/components/deluge/strings.json
+++ b/homeassistant/components/deluge/strings.json
@@ -36,6 +36,10 @@
"idle": "[%key:common::state::idle%]"
}
},
+ "downloading_count": {
+ "name": "Downloading count",
+ "unit_of_measurement": "torrents"
+ },
"download_speed": {
"name": "Download speed"
},
@@ -45,6 +49,10 @@
"protocol_traffic_upload_speed": {
"name": "Protocol traffic upload speed"
},
+ "seeding_count": {
+ "name": "Seeding count",
+ "unit_of_measurement": "[%key:component::deluge::entity::sensor::downloading_count::unit_of_measurement%]"
+ },
"upload_speed": {
"name": "Upload speed"
}
diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py
index 3d4c62ee1c7..a27dee9fcb1 100644
--- a/homeassistant/components/derivative/__init__.py
+++ b/homeassistant/components/derivative/__init__.py
@@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SOURCE: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -46,15 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py
index be371837442..f9014681088 100644
--- a/homeassistant/components/derivative/config_flow.py
+++ b/homeassistant/components/derivative/config_flow.py
@@ -140,6 +140,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 4
diff --git a/homeassistant/components/derivative/diagnostics.py b/homeassistant/components/derivative/diagnostics.py
new file mode 100644
index 00000000000..4f5496d72fe
--- /dev/null
+++ b/homeassistant/components/derivative/diagnostics.py
@@ -0,0 +1,23 @@
+"""Diagnostics support for derivative."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ registry = er.async_get(hass)
+ entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
+
+ return {
+ "config_entry": config_entry.as_dict(),
+ "entity": [entity.extended_dict for entity in entities],
+ }
diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json
index 4c5684bae75..d29c75dfaed 100644
--- a/homeassistant/components/derivative/manifest.json
+++ b/homeassistant/components/derivative/manifest.json
@@ -6,5 +6,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/derivative",
"integration_type": "helper",
- "iot_class": "calculated"
+ "iot_class": "calculated",
+ "quality_scale": "internal"
}
diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py
index 68ee5739ab7..5198c98db1e 100644
--- a/homeassistant/components/derivative/sensor.py
+++ b/homeassistant/components/derivative/sensor.py
@@ -227,15 +227,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
weight = calculate_weight(start, end, current_time)
derivative = derivative + (value * Decimal(weight))
+ _LOGGER.debug(
+ "%s: Calculated new derivative as %f from %d segments",
+ self.entity_id,
+ derivative,
+ len(self._state_list),
+ )
+
return derivative
def _prune_state_list(self, current_time: datetime) -> None:
# filter out all derivatives older than `time_window` from our window list
+ old_len = len(self._state_list)
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
if (current_time - time_end).total_seconds() < self._time_window
]
+ _LOGGER.debug(
+ "%s: Pruned %d elements from state list",
+ self.entity_id,
+ old_len - len(self._state_list),
+ )
def _handle_invalid_source_state(self, state: State | None) -> bool:
# Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false.
@@ -292,6 +305,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
) -> None:
"""Calculate derivative based on time and reschedule."""
+ _LOGGER.debug(
+ "%s: Recalculating derivative due to max_sub_interval time elapsed",
+ self.entity_id,
+ )
self._prune_state_list(now)
derivative = self._calc_derivative_from_state_list(now)
self._write_native_value(derivative)
@@ -300,6 +317,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
if derivative != 0:
schedule_max_sub_interval_exceeded(source_state)
+ _LOGGER.debug(
+ "%s: Scheduling max_sub_interval_callback in %s",
+ self.entity_id,
+ self._max_sub_interval,
+ )
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
self.hass,
self._max_sub_interval,
@@ -309,6 +331,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
@callback
def on_state_reported(event: Event[EventStateReportedData]) -> None:
"""Handle constant sensor state."""
+ _LOGGER.debug(
+ "%s: New state reported event: %s", self.entity_id, event.data
+ )
self._cancel_max_sub_interval_exceeded_callback()
new_state = event.data["new_state"]
if not self._handle_invalid_source_state(new_state):
@@ -330,6 +355,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
@callback
def on_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle changed sensor state."""
+ _LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
self._cancel_max_sub_interval_exceeded_callback()
new_state = event.data["new_state"]
if not self._handle_invalid_source_state(new_state):
@@ -382,15 +408,32 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
/ Decimal(self._unit_prefix)
* Decimal(self._unit_time)
)
+ _LOGGER.debug(
+ "%s: Calculated new derivative segment as %f / %f / %f * %f = %f",
+ self.entity_id,
+ delta_value,
+ elapsed_time,
+ self._unit_prefix,
+ self._unit_time,
+ new_derivative,
+ )
except ValueError as err:
- _LOGGER.warning("While calculating derivative: %s", err)
+ _LOGGER.warning(
+ "%s: While calculating derivative: %s", self.entity_id, err
+ )
except DecimalException as err:
_LOGGER.warning(
- "Invalid state (%s > %s): %s", old_value, new_state.state, err
+ "%s: Invalid state (%s > %s): %s",
+ self.entity_id,
+ old_value,
+ new_state.state,
+ err,
)
except AssertionError as err:
- _LOGGER.error("Could not calculate derivative: %s", err)
+ _LOGGER.error(
+ "%s: Could not calculate derivative: %s", self.entity_id, err
+ )
# For total inreasing sensors, the value is expected to continuously increase.
# A negative derivative for a total increasing sensor likely indicates the
@@ -400,6 +443,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
== SensorStateClass.TOTAL_INCREASING
and new_derivative < 0
):
+ _LOGGER.debug(
+ "%s: Dropping sample as source total_increasing sensor decreased",
+ self.entity_id,
+ )
return
# add latest derivative to the window list
diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py
index 63be9641aeb..a37a72cdcf4 100644
--- a/homeassistant/components/device_automation/condition.py
+++ b/homeassistant/components/device_automation/condition.py
@@ -6,12 +6,13 @@ from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol
-from homeassistant.const import CONF_DOMAIN
+from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
+ ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType
@@ -55,19 +56,40 @@ class DeviceAutomationConditionProtocol(Protocol):
class DeviceCondition(Condition):
"""Device condition."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize condition."""
- self._config = config
- self._hass = hass
+ _hass: HomeAssistant
+ _config: ConfigType
+
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config."""
+ complete_config = await async_validate_device_automation_config(
+ hass,
+ complete_config,
+ cv.DEVICE_CONDITION_SCHEMA,
+ DeviceAutomationType.CONDITION,
+ )
+ # Since we don't want to migrate device conditions to a new format
+ # we just pass the entire config as options.
+ complete_config[CONF_OPTIONS] = complete_config.copy()
+ return complete_config
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
- """Validate device condition config."""
- return await async_validate_device_automation_config(
- hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
- )
+ """Validate config.
+
+ This is here just to satisfy the abstract class interface. It is never called.
+ """
+ raise NotImplementedError
+
+ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
+ """Initialize condition."""
+ self._hass = hass
+ assert config.options is not None
+ self._config = config.options
async def async_get_checker(self) -> condition.ConditionCheckerType:
"""Test a device condition."""
diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py
index 7a88b12c48a..ef80005a904 100644
--- a/homeassistant/components/devolo_home_control/binary_sensor.py
+++ b/homeassistant/components/devolo_home_control/binary_sensor.py
@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._attr_translation_key = "button"
self._attr_translation_placeholders = {"key": str(key)}
- def _sync(self, message: tuple) -> None:
+ def sync_callback(self, message: tuple) -> None:
"""Update the binary sensor state."""
if (
message[0] == self._remote_control_property.element_uid
diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py
index dade8d6a2f9..ab9f29873cd 100644
--- a/homeassistant/components/devolo_home_control/entity.py
+++ b/homeassistant/components/devolo_home_control/entity.py
@@ -48,7 +48,6 @@ class DevoloDeviceEntity(Entity):
)
self.subscriber: Subscriber | None = None
- self.sync_callback = self._sync
self._value: float
@@ -69,7 +68,7 @@ class DevoloDeviceEntity(Entity):
self._device_instance.uid, self.subscriber
)
- def _sync(self, message: tuple) -> None:
+ def sync_callback(self, message: tuple) -> None:
"""Update the state."""
if message[0] == self._attr_unique_id:
self._value = message[1]
diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py
index 22581267eea..e601728d851 100644
--- a/homeassistant/components/devolo_home_control/sensor.py
+++ b/homeassistant/components/devolo_home_control/sensor.py
@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
"""
return f"{self._attr_unique_id}_{self._sensor_type}"
- def _sync(self, message: tuple) -> None:
+ def sync_callback(self, message: tuple) -> None:
"""Update the consumption sensor state."""
if message[0] == self._attr_unique_id:
self._value = getattr(
diff --git a/homeassistant/components/devolo_home_control/subscriber.py b/homeassistant/components/devolo_home_control/subscriber.py
index 99c21b3fd36..5493bdea165 100644
--- a/homeassistant/components/devolo_home_control/subscriber.py
+++ b/homeassistant/components/devolo_home_control/subscriber.py
@@ -13,8 +13,3 @@ class Subscriber:
"""Initiate the subscriber."""
self.name = name
self.callback = callback
-
- def update(self, message: str) -> None:
- """Trigger hass to update the device."""
- _LOGGER.debug('%s got message "%s"', self.name, message)
- self.callback(message)
diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py
index 378e23a5f5f..62f9326bb89 100644
--- a/homeassistant/components/devolo_home_control/switch.py
+++ b/homeassistant/components/devolo_home_control/switch.py
@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
"""Switch off the device."""
self._binary_switch_property.set(state=False)
- def _sync(self, message: tuple) -> None:
+ def sync_callback(self, message: tuple) -> None:
"""Update the binary switch state and consumption."""
if message[0].startswith("devolo.BinarySwitch"):
self._attr_is_on = self._device_instance.binary_switch_property[
diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json
index 32abe0684f7..7b8405ffc37 100644
--- a/homeassistant/components/dhcp/manifest.json
+++ b/homeassistant/components/dhcp/manifest.json
@@ -17,6 +17,6 @@
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==2.7.1",
- "cached-ipaddress==0.10.0"
+ "cached-ipaddress==1.0.1"
]
}
diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py
index d093698e26b..07509a02f86 100644
--- a/homeassistant/components/dnsip/sensor.py
+++ b/homeassistant/components/dnsip/sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
import logging
@@ -55,16 +56,16 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME]
- resolver_ipv4 = entry.options[CONF_RESOLVER]
- resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
+ nameserver_ipv4 = entry.options[CONF_RESOLVER]
+ nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = []
if entry.data[CONF_IPV4]:
- entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
+ entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
if entry.data[CONF_IPV6]:
- entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
+ entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
async_add_entities(entities, update_before_add=True)
@@ -76,11 +77,13 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
+ resolver: aiodns.DNSResolver
+
def __init__(
self,
name: str,
hostname: str,
- resolver: str,
+ nameserver: str,
ipv6: bool,
port: int,
) -> None:
@@ -88,12 +91,12 @@ class WanIpSensor(SensorEntity):
self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname
- self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port)
- self.resolver.nameservers = [resolver]
+ self.port = port
+ self.nameserver = nameserver
self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A"
self._retries = DEFAULT_RETRIES
self._attr_extra_state_attributes = {
- "resolver": resolver,
+ "resolver": nameserver,
"querytype": self.querytype,
}
self._attr_device_info = DeviceInfo(
@@ -103,14 +106,26 @@ class WanIpSensor(SensorEntity):
model=aiodns.__version__,
name=name,
)
+ self.create_dns_resolver()
+
+ def create_dns_resolver(self) -> None:
+ """Create the DNS resolver."""
+ self.resolver = aiodns.DNSResolver(
+ nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
+ )
async def async_update(self) -> None:
"""Get the current DNS IP address for hostname."""
+ if self.resolver._closed: # noqa: SLF001
+ self.create_dns_resolver()
+ response = None
try:
- response = await self.resolver.query(self.hostname, self.querytype)
+ async with asyncio.timeout(10):
+ response = await self.resolver.query(self.hostname, self.querytype)
+ except TimeoutError:
+ await self.resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
- response = None
if response:
sorted_ips = sort_ips(
diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py
index 6a954f5310f..ac08ad0e1f6 100644
--- a/homeassistant/components/doorbird/config_flow.py
+++ b/homeassistant/components/doorbird/config_flow.py
@@ -19,8 +19,10 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
+from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
@@ -103,6 +105,43 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the DoorBird config flow."""
self.discovery_schema: vol.Schema | None = None
+ async def _async_verify_existing_device_for_discovery(
+ self,
+ existing_entry: ConfigEntry,
+ host: str,
+ macaddress: str,
+ ) -> None:
+ """Verify discovered device matches existing entry before updating IP.
+
+ This method performs the following verification steps:
+ 1. Ensures that the stored credentials work before updating the entry.
+ 2. Verifies that the device at the discovered IP address has the expected MAC address.
+ """
+ info, errors = await self._async_validate_or_error(
+ {
+ **existing_entry.data,
+ CONF_HOST: host,
+ }
+ )
+
+ if errors:
+ _LOGGER.debug(
+ "Cannot validate DoorBird at %s with existing credentials: %s",
+ host,
+ errors,
+ )
+ raise AbortFlow("cannot_connect")
+
+ # Verify the MAC address matches what was advertised
+ if format_mac(info["mac_addr"]) != format_mac(macaddress):
+ _LOGGER.debug(
+ "DoorBird at %s reports MAC %s but zeroconf advertised %s, ignoring",
+ host,
+ info["mac_addr"],
+ macaddress,
+ )
+ raise AbortFlow("wrong_device")
+
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -172,7 +211,22 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(macaddress)
host = discovery_info.host
- self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+
+ # Check if we have an existing entry for this MAC
+ existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
+ DOMAIN, macaddress
+ )
+
+ if existing_entry:
+ # Check if the host is actually changing
+ if existing_entry.data.get(CONF_HOST) != host:
+ await self._async_verify_existing_device_for_discovery(
+ existing_entry, host, macaddress
+ )
+
+ # All checks passed or no change needed, abort
+ # if already configured with potential IP update
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._async_abort_entries_match({CONF_HOST: host})
diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json
index 285b544e465..341976e8a8f 100644
--- a/homeassistant/components/doorbird/strings.json
+++ b/homeassistant/components/doorbird/strings.json
@@ -49,6 +49,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"link_local_address": "Link local addresses are not supported",
"not_doorbird_device": "This device is not a DoorBird",
+ "not_ipv4_address": "Only IPv4 addresses are supported",
+ "wrong_device": "Device MAC address does not match",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"flow_title": "{name} ({host})",
diff --git a/homeassistant/components/droplet/__init__.py b/homeassistant/components/droplet/__init__.py
new file mode 100644
index 00000000000..47378742804
--- /dev/null
+++ b/homeassistant/components/droplet/__init__.py
@@ -0,0 +1,37 @@
+"""The Droplet integration."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import DropletConfigEntry, DropletDataCoordinator
+
+PLATFORMS: list[Platform] = [
+ Platform.SENSOR,
+]
+
+logger = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: DropletConfigEntry
+) -> bool:
+ """Set up Droplet from a config entry."""
+
+ droplet_coordinator = DropletDataCoordinator(hass, config_entry)
+ await droplet_coordinator.async_config_entry_first_refresh()
+ config_entry.runtime_data = droplet_coordinator
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: DropletConfigEntry
+) -> bool:
+ """Unload a config entry."""
+
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/droplet/config_flow.py b/homeassistant/components/droplet/config_flow.py
new file mode 100644
index 00000000000..c08e8c608e5
--- /dev/null
+++ b/homeassistant/components/droplet/config_flow.py
@@ -0,0 +1,118 @@
+"""Config flow for Droplet integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydroplet.droplet import DropletConnection, DropletDiscovery
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_CODE, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PORT
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
+
+from .const import DOMAIN
+
+
+class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle Droplet config flow."""
+
+ _droplet_discovery: DropletDiscovery
+
+ async def async_step_zeroconf(
+ self, discovery_info: ZeroconfServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle zeroconf discovery."""
+ self._droplet_discovery = DropletDiscovery(
+ discovery_info.host,
+ discovery_info.port,
+ discovery_info.name,
+ )
+ if not self._droplet_discovery.is_valid():
+ return self.async_abort(reason="invalid_discovery_info")
+
+ # In this case, device ID was part of the zeroconf discovery info
+ device_id: str = await self._droplet_discovery.get_device_id()
+ await self.async_set_unique_id(device_id)
+
+ self._abort_if_unique_id_configured(
+ updates={CONF_IP_ADDRESS: self._droplet_discovery.host},
+ )
+
+ self.context.update({"title_placeholders": {"name": device_id}})
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the setup."""
+ errors: dict[str, str] = {}
+ device_id: str = await self._droplet_discovery.get_device_id()
+ if user_input is not None:
+ # Test if we can connect before returning
+ session = async_get_clientsession(self.hass)
+ if await self._droplet_discovery.try_connect(
+ session, user_input[CONF_CODE]
+ ):
+ device_data = {
+ CONF_IP_ADDRESS: self._droplet_discovery.host,
+ CONF_PORT: self._droplet_discovery.port,
+ CONF_DEVICE_ID: device_id,
+ CONF_CODE: user_input[CONF_CODE],
+ }
+
+ return self.async_create_entry(
+ title=device_id,
+ data=device_data,
+ )
+ errors["base"] = "cannot_connect"
+ return self.async_show_form(
+ step_id="confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_CODE): str,
+ }
+ ),
+ description_placeholders={
+ "device_name": device_id,
+ },
+ errors=errors,
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self._droplet_discovery = DropletDiscovery(
+ user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, ""
+ )
+ session = async_get_clientsession(self.hass)
+ if await self._droplet_discovery.try_connect(
+ session, user_input[CONF_CODE]
+ ) and (device_id := await self._droplet_discovery.get_device_id()):
+ device_data = {
+ CONF_IP_ADDRESS: self._droplet_discovery.host,
+ CONF_PORT: self._droplet_discovery.port,
+ CONF_DEVICE_ID: device_id,
+ CONF_CODE: user_input[CONF_CODE],
+ }
+ await self.async_set_unique_id(device_id, raise_on_progress=False)
+ self._abort_if_unique_id_configured(
+ description_placeholders={CONF_DEVICE_ID: device_id},
+ )
+
+ return self.async_create_entry(
+ title=device_id,
+ data=device_data,
+ )
+ errors["base"] = "cannot_connect"
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_CODE): str}
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/droplet/const.py b/homeassistant/components/droplet/const.py
new file mode 100644
index 00000000000..3456b4fe432
--- /dev/null
+++ b/homeassistant/components/droplet/const.py
@@ -0,0 +1,11 @@
+"""Constants for the droplet integration."""
+
+CONNECT_DELAY = 5
+
+DOMAIN = "droplet"
+DEVICE_NAME = "Droplet"
+
+KEY_CURRENT_FLOW_RATE = "current_flow_rate"
+KEY_VOLUME = "volume"
+KEY_SIGNAL_QUALITY = "signal_quality"
+KEY_SERVER_CONNECTIVITY = "server_connectivity"
diff --git a/homeassistant/components/droplet/coordinator.py b/homeassistant/components/droplet/coordinator.py
new file mode 100644
index 00000000000..33a5468ebd8
--- /dev/null
+++ b/homeassistant/components/droplet/coordinator.py
@@ -0,0 +1,84 @@
+"""Droplet device data update coordinator object."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+
+from pydroplet.droplet import Droplet
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import CONNECT_DELAY, DOMAIN
+
+VERSION_TIMEOUT = 5
+
+_LOGGER = logging.getLogger(__name__)
+
+TIMEOUT = 1
+
+type DropletConfigEntry = ConfigEntry[DropletDataCoordinator]
+
+
+class DropletDataCoordinator(DataUpdateCoordinator[None]):
+ """Droplet device object."""
+
+ config_entry: DropletConfigEntry
+
+ def __init__(self, hass: HomeAssistant, entry: DropletConfigEntry) -> None:
+ """Initialize the device."""
+ super().__init__(
+ hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}"
+ )
+ self.droplet = Droplet(
+ host=entry.data[CONF_IP_ADDRESS],
+ port=entry.data[CONF_PORT],
+ token=entry.data[CONF_CODE],
+ session=async_get_clientsession(self.hass),
+ logger=_LOGGER,
+ )
+ assert entry.unique_id is not None
+ self.unique_id = entry.unique_id
+
+ async def _async_setup(self) -> None:
+ if not await self.setup():
+ raise ConfigEntryNotReady("Device is offline")
+
+ # Droplet should send its metadata within 5 seconds
+ end = time.time() + VERSION_TIMEOUT
+ while not self.droplet.version_info_available():
+ await asyncio.sleep(TIMEOUT)
+ if time.time() > end:
+ _LOGGER.warning("Failed to get version info from Droplet")
+ return
+
+ async def _async_update_data(self) -> None:
+ if not self.droplet.connected:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="connection_error"
+ )
+
+ async def setup(self) -> bool:
+ """Set up droplet client."""
+ self.config_entry.async_on_unload(self.droplet.stop_listening)
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self.droplet.listen_forever(CONNECT_DELAY, self.async_set_updated_data),
+ "droplet-listen",
+ )
+ end = time.time() + CONNECT_DELAY
+ while time.time() < end:
+ if self.droplet.connected:
+ return True
+ await asyncio.sleep(TIMEOUT)
+ return False
+
+ def get_availability(self) -> bool:
+ """Retrieve Droplet's availability status."""
+ return self.droplet.get_availability()
diff --git a/homeassistant/components/droplet/icons.json b/homeassistant/components/droplet/icons.json
new file mode 100644
index 00000000000..43e87959490
--- /dev/null
+++ b/homeassistant/components/droplet/icons.json
@@ -0,0 +1,15 @@
+{
+ "entity": {
+ "sensor": {
+ "current_flow_rate": {
+ "default": "mdi:chart-line"
+ },
+ "server_connectivity": {
+ "default": "mdi:web"
+ },
+ "signal_quality": {
+ "default": "mdi:waveform"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json
new file mode 100644
index 00000000000..f4a03ebfb21
--- /dev/null
+++ b/homeassistant/components/droplet/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "droplet",
+ "name": "Droplet",
+ "codeowners": ["@sarahseidman"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/droplet",
+ "iot_class": "local_push",
+ "quality_scale": "bronze",
+ "requirements": ["pydroplet==2.3.3"],
+ "zeroconf": ["_droplet._tcp.local."]
+}
diff --git a/homeassistant/components/droplet/quality_scale.yaml b/homeassistant/components/droplet/quality_scale.yaml
new file mode 100644
index 00000000000..5ef0df9f3cc
--- /dev/null
+++ b/homeassistant/components/droplet/quality_scale.yaml
@@ -0,0 +1,72 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions defined
+ appropriate-polling:
+ status: exempt
+ comment: |
+ No polling
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/droplet/sensor.py b/homeassistant/components/droplet/sensor.py
new file mode 100644
index 00000000000..73420abc121
--- /dev/null
+++ b/homeassistant/components/droplet/sensor.py
@@ -0,0 +1,131 @@
+"""Support for Droplet."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from pydroplet.droplet import Droplet
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import EntityCategory, UnitOfVolume, UnitOfVolumeFlowRate
+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,
+ KEY_CURRENT_FLOW_RATE,
+ KEY_SERVER_CONNECTIVITY,
+ KEY_SIGNAL_QUALITY,
+ KEY_VOLUME,
+)
+from .coordinator import DropletConfigEntry, DropletDataCoordinator
+
+ML_L_CONVERSION = 1000
+
+
+@dataclass(kw_only=True, frozen=True)
+class DropletSensorEntityDescription(SensorEntityDescription):
+ """Describes Droplet sensor entity."""
+
+ value_fn: Callable[[Droplet], float | str | None]
+ last_reset_fn: Callable[[Droplet], datetime | None] = lambda _: None
+
+
+SENSORS: list[DropletSensorEntityDescription] = [
+ DropletSensorEntityDescription(
+ key=KEY_CURRENT_FLOW_RATE,
+ translation_key=KEY_CURRENT_FLOW_RATE,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ suggested_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda device: device.get_flow_rate(),
+ ),
+ DropletSensorEntityDescription(
+ key=KEY_VOLUME,
+ device_class=SensorDeviceClass.WATER,
+ native_unit_of_measurement=UnitOfVolume.LITERS,
+ suggested_unit_of_measurement=UnitOfVolume.GALLONS,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda device: device.get_volume_delta() / ML_L_CONVERSION,
+ last_reset_fn=lambda device: device.get_volume_last_fetched(),
+ ),
+ DropletSensorEntityDescription(
+ key=KEY_SERVER_CONNECTIVITY,
+ translation_key=KEY_SERVER_CONNECTIVITY,
+ device_class=SensorDeviceClass.ENUM,
+ options=["connected", "connecting", "disconnected"],
+ value_fn=lambda device: device.get_server_status(),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DropletSensorEntityDescription(
+ key=KEY_SIGNAL_QUALITY,
+ translation_key=KEY_SIGNAL_QUALITY,
+ device_class=SensorDeviceClass.ENUM,
+ options=["no_signal", "weak_signal", "strong_signal"],
+ value_fn=lambda device: device.get_signal_quality(),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: DropletConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Droplet sensors from config entry."""
+ coordinator = config_entry.runtime_data
+ async_add_entities([DropletSensor(coordinator, sensor) for sensor in SENSORS])
+
+
+class DropletSensor(CoordinatorEntity[DropletDataCoordinator], SensorEntity):
+ """Representation of a Droplet."""
+
+ entity_description: DropletSensorEntityDescription
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: DropletDataCoordinator,
+ entity_description: DropletSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+
+ unique_id = coordinator.config_entry.unique_id
+ self._attr_unique_id = f"{unique_id}_{entity_description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self.coordinator.unique_id)},
+ manufacturer=self.coordinator.droplet.get_manufacturer(),
+ model=self.coordinator.droplet.get_model(),
+ sw_version=self.coordinator.droplet.get_fw_version(),
+ serial_number=self.coordinator.droplet.get_sn(),
+ )
+
+ @property
+ def available(self) -> bool:
+ """Get Droplet's availability."""
+ return self.coordinator.get_availability()
+
+ @property
+ def native_value(self) -> float | str | None:
+ """Return the value reported by the sensor."""
+ return self.entity_description.value_fn(self.coordinator.droplet)
+
+ @property
+ def last_reset(self) -> datetime | None:
+ """Return the last reset of the sensor, if applicable."""
+ return self.entity_description.last_reset_fn(self.coordinator.droplet)
diff --git a/homeassistant/components/droplet/strings.json b/homeassistant/components/droplet/strings.json
new file mode 100644
index 00000000000..dd3697708bf
--- /dev/null
+++ b/homeassistant/components/droplet/strings.json
@@ -0,0 +1,46 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Configure Droplet integration",
+ "description": "Manually enter Droplet's connection details.",
+ "data": {
+ "ip_address": "[%key:common::config_flow::data::ip%]",
+ "code": "Pairing code"
+ },
+ "data_description": {
+ "ip_address": "Droplet's IP address",
+ "code": "Code from the Droplet app"
+ }
+ },
+ "confirm": {
+ "title": "Confirm association",
+ "description": "Enter pairing code to connect to {device_name}.",
+ "data": {
+ "code": "[%key:component::droplet::config::step::user::data::code%]"
+ },
+ "data_description": {
+ "code": "[%key:component::droplet::config::step::user::data_description::code%]"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "server_connectivity": { "name": "Server status" },
+ "signal_quality": { "name": "Signal quality" },
+ "current_flow_rate": { "name": "Flow rate" }
+ }
+ },
+ "exceptions": {
+ "connection_error": {
+ "message": "Disconnected from Droplet"
+ }
+ }
+}
diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py
index 4cb8d92c391..5c36c311bff 100644
--- a/homeassistant/components/ebusd/__init__.py
+++ b/homeassistant/components/ebusd/__init__.py
@@ -116,7 +116,11 @@ class EbusdData:
try:
_LOGGER.debug("Opening socket to ebusd %s", name)
command_result = ebusdpy.write(self._address, self._circuit, name, value)
- if command_result is not None and "done" not in command_result:
+ if (
+ command_result is not None
+ and "done" not in command_result
+ and "empty" not in command_result
+ ):
_LOGGER.warning("Write command failed: %s", name)
except RuntimeError as err:
_LOGGER.error(err)
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index bc61cb444c1..b5cec285811 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -175,12 +175,8 @@
"name": "Set sensors used in climate",
"description": "Sets the participating sensors for a climate program.",
"fields": {
- "entity_id": {
- "name": "Entity",
- "description": "ecobee thermostat on which to set active sensors."
- },
"preset_mode": {
- "name": "Climate Name",
+ "name": "Climate program",
"description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program."
},
"device_ids": {
@@ -192,7 +188,7 @@
},
"exceptions": {
"invalid_preset": {
- "message": "Invalid climate name, available options are: {options}"
+ "message": "Invalid climate program, available options are: {options}"
},
"invalid_sensor": {
"message": "Invalid sensor for thermostat, available options are: {options}"
diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py
index b1c2f0075f1..5fa00fc5e43 100644
--- a/homeassistant/components/ecovacs/image.py
+++ b/homeassistant/components/ecovacs/image.py
@@ -69,7 +69,9 @@ class EcovacsMap(
await super().async_added_to_hass()
async def on_info(event: CachedMapInfoEvent) -> None:
- self._attr_extra_state_attributes["map_name"] = event.name
+ for map_obj in event.maps:
+ if map_obj.using:
+ self._attr_extra_state_attributes["map_name"] = map_obj.name
async def on_changed(event: MapChangedEvent) -> None:
self._attr_image_last_updated = event.when
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index ddd464bdc6a..8d57eda6f4c 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
+ "requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"]
}
diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py
index 513a0d350f6..e8cefbd6d1f 100644
--- a/homeassistant/components/ecovacs/number.py
+++ b/homeassistant/components/ecovacs/number.py
@@ -5,9 +5,11 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from deebot_client.capabilities import CapabilitySet
+from deebot_client.capabilities import CapabilityNumber, CapabilitySet
+from deebot_client.device import Device
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
from deebot_client.events.base import Event
+from deebot_client.events.water_info import WaterCustomAmountEvent
from homeassistant.components.number import (
NumberEntity,
@@ -75,6 +77,19 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = (
native_step=1.0,
mode=NumberMode.BOX,
),
+ EcovacsNumberEntityDescription[WaterCustomAmountEvent](
+ capability_fn=lambda caps: (
+ caps.water.amount
+ if caps.water and isinstance(caps.water.amount, CapabilityNumber)
+ else None
+ ),
+ value_fn=lambda e: e.value,
+ key="water_amount",
+ translation_key="water_amount",
+ entity_category=EntityCategory.CONFIG,
+ native_step=1.0,
+ mode=NumberMode.BOX,
+ ),
)
@@ -100,6 +115,18 @@ class EcovacsNumberEntity[EventT: Event](
entity_description: EcovacsNumberEntityDescription
+ def __init__(
+ self,
+ device: Device,
+ capability: CapabilitySet[EventT, [int]],
+ entity_description: EcovacsNumberEntityDescription,
+ ) -> None:
+ """Initialize entity."""
+ super().__init__(device, capability, entity_description)
+ if isinstance(capability, CapabilityNumber):
+ self._attr_native_min_value = capability.min
+ self._attr_native_max_value = capability.max
+
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py
index 84f86fdd2cd..440141bbcee 100644
--- a/homeassistant/components/ecovacs/select.py
+++ b/homeassistant/components/ecovacs/select.py
@@ -33,7 +33,11 @@ class EcovacsSelectEntityDescription[EventT: Event](
ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
EcovacsSelectEntityDescription[WaterAmountEvent](
- capability_fn=lambda caps: caps.water.amount if caps.water else None,
+ capability_fn=lambda caps: (
+ caps.water.amount
+ if caps.water and isinstance(caps.water.amount, CapabilitySetTypes)
+ else None
+ ),
current_option_fn=lambda e: get_name_key(e.value),
options_fn=lambda water: [get_name_key(amount) for amount in water.types],
key="water_amount",
diff --git a/homeassistant/components/ecovacs/services.yaml b/homeassistant/components/ecovacs/services.yaml
index 0d884a24feb..1d32ff6f866 100644
--- a/homeassistant/components/ecovacs/services.yaml
+++ b/homeassistant/components/ecovacs/services.yaml
@@ -2,3 +2,4 @@ raw_get_positions:
target:
entity:
domain: vacuum
+ integration: ecovacs
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index 1be81ab1292..e69da61799f 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -102,6 +102,9 @@
},
"volume": {
"name": "Volume"
+ },
+ "water_amount": {
+ "name": "Water flow level"
}
},
"sensor": {
@@ -152,8 +155,10 @@
"station_state": {
"name": "Station state",
"state": {
+ "drying_mop": "Drying mop",
"idle": "[%key:common::state::idle%]",
- "emptying_dustbin": "Emptying dustbin"
+ "emptying_dustbin": "Emptying dustbin",
+ "washing_mop": "Washing mop"
}
},
"stats_area": {
@@ -174,7 +179,7 @@
},
"select": {
"water_amount": {
- "name": "Water flow level",
+ "name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py
index 968ab92851b..d26bd1981d7 100644
--- a/homeassistant/components/ecovacs/util.py
+++ b/homeassistant/components/ecovacs/util.py
@@ -7,8 +7,6 @@ import random
import string
from typing import TYPE_CHECKING
-from deebot_client.events.station import State
-
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
@@ -49,9 +47,6 @@ def get_supported_entities(
@callback
def get_name_key(enum: Enum) -> str:
"""Return the lower case name of the enum."""
- if enum is State.EMPTYING:
- # Will be fixed in the next major release of deebot-client
- return "emptying_dustbin"
return enum.name.lower()
diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json
index 3ce66f48f95..d8b8aedbc3d 100644
--- a/homeassistant/components/ecowitt/manifest.json
+++ b/homeassistant/components/ecowitt/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
- "requirements": ["aioecowitt==2025.3.1"]
+ "requirements": ["aioecowitt==2025.9.2"]
}
diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py
index ccaaeaae3de..6990bf56099 100644
--- a/homeassistant/components/ecowitt/sensor.py
+++ b/homeassistant/components/ecowitt/sensor.py
@@ -152,24 +152,28 @@ ECOWITT_SENSORS_MAPPING: Final = {
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=1,
),
EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription(
key="RAIN_COUNT_INCHES",
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=2,
),
EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription(
key="RAIN_RATE_MM",
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
+ suggested_display_precision=1,
),
EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription(
key="RAIN_RATE_INCHES",
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
+ suggested_display_precision=2,
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription(
key="LIGHTNING_DISTANCE_KM",
@@ -213,11 +217,46 @@ ECOWITT_SENSORS_MAPPING: Final = {
native_unit_of_measurement=UnitOfPressure.INHG,
state_class=SensorStateClass.MEASUREMENT,
),
+ EcoWittSensorTypes.VPD_INHG: SensorEntityDescription(
+ key="VPD_INHG",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.INHG,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
EcoWittSensorTypes.PERCENTAGE: SensorEntityDescription(
key="PERCENTAGE",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
+ EcoWittSensorTypes.SOIL_MOISTURE: SensorEntityDescription(
+ key="SOIL_MOISTURE",
+ device_class=SensorDeviceClass.MOISTURE,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription(
+ key="DISTANCE_MM",
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.MILLIMETERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription(
+ key="HEAT_COUNT",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ EcoWittSensorTypes.PM1: SensorEntityDescription(
+ key="PM1",
+ device_class=SensorDeviceClass.PM1,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ EcoWittSensorTypes.PM4: SensorEntityDescription(
+ key="PM4",
+ device_class=SensorDeviceClass.PM4,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
}
diff --git a/homeassistant/components/ekeybionyx/__init__.py b/homeassistant/components/ekeybionyx/__init__.py
new file mode 100644
index 00000000000..672824b811a
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/__init__.py
@@ -0,0 +1,24 @@
+"""The Ekey Bionyx integration."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+PLATFORMS: list[Platform] = [Platform.EVENT]
+
+
+type EkeyBionyxConfigEntry = ConfigEntry
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
+ """Set up the Ekey Bionyx config entry."""
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ekeybionyx/application_credentials.py b/homeassistant/components/ekeybionyx/application_credentials.py
new file mode 100644
index 00000000000..d6b7918af6b
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/application_credentials.py
@@ -0,0 +1,14 @@
+"""application_credentials platform the Ekey Bionyx integration."""
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ authorize_url=OAUTH2_AUTHORIZE,
+ token_url=OAUTH2_TOKEN,
+ )
diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py
new file mode 100644
index 00000000000..cdf0538eea5
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/config_flow.py
@@ -0,0 +1,271 @@
+"""Config flow for ekey bionyx."""
+
+import asyncio
+import json
+import logging
+import re
+import secrets
+from typing import Any, NotRequired, TypedDict
+
+import aiohttp
+import ekey_bionyxpy
+import voluptuous as vol
+
+from homeassistant.components.webhook import (
+ async_generate_id as webhook_generate_id,
+ async_generate_path as webhook_generate_path,
+)
+from homeassistant.config_entries import ConfigFlowResult
+from homeassistant.const import CONF_TOKEN, CONF_URL
+from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.network import get_url
+from homeassistant.helpers.selector import SelectOptionDict, SelectSelector
+
+from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
+
+# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot
+VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
+
+
+class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
+ """ekey bionyx authentication before a ConfigEntry exists.
+
+ This implementation directly provides the token without supporting refresh.
+ """
+
+ def __init__(
+ self,
+ websession: aiohttp.ClientSession,
+ token: dict[str, Any],
+ ) -> None:
+ """Initialize ConfigFlowEkeyApi."""
+ super().__init__(websession, API_URL)
+ self._token = token
+
+ async def async_get_access_token(self) -> str:
+ """Return the token for the Ekey API."""
+ return self._token["access_token"]
+
+
+class EkeyFlowData(TypedDict):
+ """Type for Flow Data."""
+
+ api: NotRequired[ekey_bionyxpy.BionyxAPI]
+ system: NotRequired[ekey_bionyxpy.System]
+ systems: NotRequired[list[ekey_bionyxpy.System]]
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle ekey bionyx OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ check_deletion_task: asyncio.Task[None] | None = None
+
+ def __init__(self) -> None:
+ """Initialize OAuth2FlowHandler."""
+ super().__init__()
+ self._data: EkeyFlowData = {}
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {"scope": SCOPE}
+
+ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
+ """Start the user facing flow by initializing the API and getting the systems."""
+ client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN])
+ ap = ekey_bionyxpy.BionyxAPI(client)
+ self._data["api"] = ap
+ try:
+ system_res = await ap.get_systems()
+ except aiohttp.ClientResponseError:
+ return self.async_abort(
+ reason="cannot_connect",
+ description_placeholders={"ekeybionyx": INTEGRATION_NAME},
+ )
+ system = [s for s in system_res if s.own_system]
+ if len(system) == 0:
+ return self.async_abort(reason="no_own_systems")
+ self._data["systems"] = system
+ if len(system) == 1:
+ # skipping choose_system since there is only one
+ self._data["system"] = system[0]
+ return await self.async_step_check_system(user_input=None)
+ return await self.async_step_choose_system(user_input=None)
+
+ async def async_step_choose_system(
+ self, user_input: dict[str, Any] | None
+ ) -> ConfigFlowResult:
+ """Dialog to choose System if multiple systems are present."""
+ if user_input is None:
+ options: list[SelectOptionDict] = [
+ {"value": s.system_id, "label": s.system_name}
+ for s in self._data["systems"]
+ ]
+ data_schema = {vol.Required("system"): SelectSelector({"options": options})}
+ return self.async_show_form(
+ step_id="choose_system",
+ data_schema=vol.Schema(data_schema),
+ description_placeholders={"ekeybionyx": INTEGRATION_NAME},
+ )
+ self._data["system"] = [
+ s for s in self._data["systems"] if s.system_id == user_input["system"]
+ ][0]
+ return await self.async_step_check_system(user_input=None)
+
+ async def async_step_check_system(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Check if system has open webhooks."""
+ system = self._data["system"]
+ await self.async_set_unique_id(system.system_id)
+ self._abort_if_unique_id_configured()
+
+ if (
+ system.function_webhook_quotas["free"] == 0
+ and system.function_webhook_quotas["used"] == 0
+ ):
+ return self.async_abort(
+ reason="no_available_webhooks",
+ description_placeholders={"ekeybionyx": INTEGRATION_NAME},
+ )
+
+ if system.function_webhook_quotas["used"] > 0:
+ return await self.async_step_delete_webhooks()
+ return await self.async_step_webhooks(user_input=None)
+
+ async def async_step_webhooks(
+ self, user_input: dict[str, Any] | None
+ ) -> ConfigFlowResult:
+ """Dialog to setup webhooks."""
+ system = self._data["system"]
+
+ errors: dict[str, str] | None = None
+ if user_input is not None:
+ errors = {}
+ for key, webhook_name in user_input.items():
+ if key == CONF_URL:
+ continue
+ if not re.match(VALID_NAME_PATTERN, webhook_name):
+ errors.update({key: "invalid_name"})
+ try:
+ cv.url(user_input[CONF_URL])
+ except vol.Invalid:
+ errors[CONF_URL] = "invalid_url"
+ if set(user_input) == {CONF_URL}:
+ errors["base"] = "no_webhooks_provided"
+
+ if not errors:
+ webhook_data = [
+ {
+ "auth": secrets.token_hex(32),
+ "name": webhook_name,
+ "webhook_id": webhook_generate_id(),
+ }
+ for key, webhook_name in user_input.items()
+ if key != CONF_URL
+ ]
+ for webhook in webhook_data:
+ wh_def: ekey_bionyxpy.WebhookData = {
+ "integrationName": "Home Assistant",
+ "functionName": webhook["name"],
+ "locationName": "Home Assistant",
+ "definition": {
+ "url": user_input[CONF_URL]
+ + webhook_generate_path(webhook["webhook_id"]),
+ "authentication": {"apiAuthenticationType": "None"},
+ "securityLevel": "AllowHttp",
+ "method": "Post",
+ "body": {
+ "contentType": "application/json",
+ "content": json.dumps({"auth": webhook["auth"]}),
+ },
+ },
+ }
+ webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id
+ return self.async_create_entry(
+ title=self._data["system"].system_name,
+ data={"webhooks": webhook_data},
+ )
+
+ data_schema: dict[Any, Any] = {
+ vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50))
+ for i in range(self._data["system"].function_webhook_quotas["free"])
+ }
+ data_schema[vol.Required(CONF_URL)] = str
+ return self.async_show_form(
+ step_id="webhooks",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(data_schema),
+ {
+ CONF_URL: get_url(
+ self.hass,
+ allow_ip=True,
+ prefer_external=False,
+ )
+ }
+ | (user_input or {}),
+ ),
+ errors=errors,
+ description_placeholders={
+ "webhooks_available": str(
+ self._data["system"].function_webhook_quotas["free"]
+ ),
+ "ekeybionyx": INTEGRATION_NAME,
+ },
+ )
+
+ async def async_step_delete_webhooks(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Form to delete Webhooks."""
+ if user_input is None:
+ return self.async_show_form(step_id="delete_webhooks")
+ for webhook in await self._data["system"].get_webhooks():
+ await webhook.delete()
+ return await self.async_step_wait_for_deletion(user_input=None)
+
+ async def async_step_wait_for_deletion(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Wait for webhooks to be deleted in another flow."""
+ uncompleted_task: asyncio.Task[None] | None = None
+
+ if not self.check_deletion_task:
+ self.check_deletion_task = self.hass.async_create_task(
+ self.async_check_deletion_status()
+ )
+ if not self.check_deletion_task.done():
+ progress_action = "check_deletion_status"
+ uncompleted_task = self.check_deletion_task
+ if uncompleted_task:
+ return self.async_show_progress(
+ step_id="wait_for_deletion",
+ description_placeholders={"ekeybionyx": INTEGRATION_NAME},
+ progress_action=progress_action,
+ progress_task=uncompleted_task,
+ )
+ self.check_deletion_task = None
+ return self.async_show_progress_done(next_step_id="webhooks")
+
+ async def async_check_deletion_status(self) -> None:
+ """Check if webhooks have been deleted."""
+ while True:
+ self._data["systems"] = await self._data["api"].get_systems()
+ self._data["system"] = [
+ s
+ for s in self._data["systems"]
+ if s.system_id == self._data["system"].system_id
+ ][0]
+ if self._data["system"].function_webhook_quotas["used"] == 0:
+ break
+ await asyncio.sleep(5)
diff --git a/homeassistant/components/ekeybionyx/const.py b/homeassistant/components/ekeybionyx/const.py
new file mode 100644
index 00000000000..eaf5b87f874
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/const.py
@@ -0,0 +1,13 @@
+"""Constants for the Ekey Bionyx integration."""
+
+import logging
+
+DOMAIN = "ekeybionyx"
+INTEGRATION_NAME = "ekey bionyx"
+
+LOGGER = logging.getLogger(__package__)
+
+OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize"
+OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token"
+API_URL = "https://api.bionyx.io/3rd-party/api"
+SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access"
diff --git a/homeassistant/components/ekeybionyx/event.py b/homeassistant/components/ekeybionyx/event.py
new file mode 100644
index 00000000000..b847637465b
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/event.py
@@ -0,0 +1,70 @@
+"""Event platform for ekey bionyx integration."""
+
+from aiohttp.hdrs import METH_POST
+from aiohttp.web import Request, Response
+
+from homeassistant.components.event import EventDeviceClass, EventEntity
+from homeassistant.components.webhook import (
+ async_register as webhook_register,
+ async_unregister as webhook_unregister,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import EkeyBionyxConfigEntry
+from .const import DOMAIN
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: EkeyBionyxConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Ekey event."""
+ async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"])
+
+
+class EkeyEvent(EventEntity):
+ """Ekey Event."""
+
+ _attr_device_class = EventDeviceClass.BUTTON
+ _attr_event_types = ["event happened"]
+
+ def __init__(
+ self,
+ data: dict[str, str],
+ ) -> None:
+ """Initialise a Ekey event entity."""
+ self._attr_name = data["name"]
+ self._attr_unique_id = data["ekey_id"]
+ self._webhook_id = data["webhook_id"]
+ self._auth = data["auth"]
+
+ @callback
+ def _async_handle_event(self) -> None:
+ """Handle the webhook event."""
+ self._trigger_event("event happened")
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks with your device API/library."""
+
+ async def async_webhook_handler(
+ hass: HomeAssistant, webhook_id: str, request: Request
+ ) -> Response | None:
+ if (await request.json())["auth"] == self._auth:
+ self._async_handle_event()
+ return None
+
+ webhook_register(
+ self.hass,
+ DOMAIN,
+ f"Ekey {self._attr_name}",
+ self._webhook_id,
+ async_webhook_handler,
+ allowed_methods=[METH_POST],
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister Webhook."""
+ webhook_unregister(self.hass, self._webhook_id)
diff --git a/homeassistant/components/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json
new file mode 100644
index 00000000000..a53dc13b993
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "ekeybionyx",
+ "name": "ekey bionyx",
+ "codeowners": ["@richardpolzer"],
+ "config_flow": true,
+ "dependencies": ["application_credentials", "http"],
+ "documentation": "https://www.home-assistant.io/integrations/ekeybionyx",
+ "iot_class": "local_push",
+ "quality_scale": "bronze",
+ "requirements": ["ekey-bionyxpy==1.0.0"]
+}
diff --git a/homeassistant/components/ekeybionyx/quality_scale.yaml b/homeassistant/components/ekeybionyx/quality_scale.yaml
new file mode 100644
index 00000000000..13122e56adf
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide actions.
+ appropriate-polling:
+ status: exempt
+ comment: This integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: This integration does not provide actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data:
+ status: exempt
+ comment: This integration does not connect to any device or service.
+ test-before-configure: done
+ test-before-setup:
+ status: exempt
+ comment: This integration does not connect to any device or service.
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: This integration does not provide actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable:
+ status: exempt
+ comment: This integration has no way of knowing if the fingerprint reader is offline.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: This integration has no way of knowing if the fingerprint reader is offline.
+ parallel-updates:
+ status: exempt
+ comment: This integration does not poll.
+ reauthentication-flow:
+ status: exempt
+ comment: This integration does not store the tokens.
+ test-coverage: todo
+
+ # Gold
+ devices:
+ status: exempt
+ comment: This integration does not connect to any device or service.
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: This integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: This integration does not support discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: This integration does not connect to any device or service.
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: This integration has no entities that should be disabled by default.
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: This integration does not connect to any device or service.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/ekeybionyx/strings.json b/homeassistant/components/ekeybionyx/strings.json
new file mode 100644
index 00000000000..14ad5de5aa4
--- /dev/null
+++ b/homeassistant/components/ekeybionyx/strings.json
@@ -0,0 +1,66 @@
+{
+ "config": {
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "choose_system": {
+ "data": {
+ "system": "System"
+ },
+ "data_description": {
+ "system": "System the event entities should be set up for."
+ },
+ "description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant."
+ },
+ "webhooks": {
+ "description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.",
+ "data": {
+ "webhook1": "Event entity 1",
+ "webhook2": "Event entity 2",
+ "webhook3": "Event entity 3",
+ "webhook4": "Event entity 4",
+ "webhook5": "Event entity 5",
+ "url": "Home Assistant URL"
+ },
+ "data_description": {
+ "webhook1": "Name of event entity 1 that will be mapped into a function",
+ "webhook2": "Name of event entity 2 that will be mapped into a function",
+ "webhook3": "Name of event entity 3 that will be mapped into a function",
+ "webhook4": "Name of event entity 4 that will be mapped into a function",
+ "webhook5": "Name of event entity 5 that will be mapped into a function",
+ "url": "Home Assistant instance URL which can be reached from the fingerprint controller"
+ }
+ },
+ "delete_webhooks": {
+ "description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted."
+ }
+ },
+ "progress": {
+ "check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions."
+ },
+ "error": {
+ "invalid_name": "Name is invalid",
+ "invalid_url": "URL is invalid",
+ "no_webhooks_provided": "No event names provided"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.",
+ "no_own_systems": "Your account does not have admin access to any systems.",
+ "cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again."
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
+ }
+ }
+}
diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py
index c486a385721..8eeff19ce4f 100644
--- a/homeassistant/components/elkm1/config_flow.py
+++ b/homeassistant/components/elkm1/config_flow.py
@@ -120,6 +120,14 @@ def _make_url_from_data(data: dict[str, str]) -> str:
return f"{protocol}{address}"
+def _get_protocol_from_url(url: str) -> str:
+ """Get protocol from URL. Returns the configured protocol from URL or the default secure protocol."""
+ return next(
+ (k for k, v in PROTOCOL_MAP.items() if url.startswith(v)),
+ DEFAULT_SECURE_PROTOCOL,
+ )
+
+
def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
return {
"mac_address": _short_mac(device.mac_address),
@@ -205,6 +213,78 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
)
return await self.async_step_discovered_connection()
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+ existing_data = reconfigure_entry.data
+
+ if user_input is not None:
+ validate_input_data = dict(user_input)
+ validate_input_data[CONF_PREFIX] = existing_data.get(CONF_PREFIX, "")
+
+ try:
+ info = await validate_input(
+ validate_input_data, reconfigure_entry.unique_id
+ )
+ except TimeoutError:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors[CONF_PASSWORD] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception during reconfiguration")
+ errors["base"] = "unknown"
+ else:
+ # Discover the device at the provided address to obtain its MAC (unique_id)
+ device = await async_discover_device(
+ self.hass, validate_input_data[CONF_ADDRESS]
+ )
+ if device is not None and device.mac_address:
+ await self.async_set_unique_id(dr.format_mac(device.mac_address))
+ self._abort_if_unique_id_mismatch() # aborts if user tried to switch devices
+ else:
+ # If we cannot confirm identity, keep existing behavior (don't block reconfigure)
+ await self.async_set_unique_id(reconfigure_entry.unique_id)
+
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates={
+ **reconfigure_entry.data,
+ CONF_HOST: info[CONF_HOST],
+ CONF_USERNAME: validate_input_data[CONF_USERNAME],
+ CONF_PASSWORD: validate_input_data[CONF_PASSWORD],
+ CONF_PREFIX: info[CONF_PREFIX],
+ },
+ reason="reconfigure_successful",
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_USERNAME,
+ default=existing_data.get(CONF_USERNAME, ""),
+ ): str,
+ vol.Optional(
+ CONF_PASSWORD,
+ default="",
+ ): str,
+ vol.Required(
+ CONF_ADDRESS,
+ default=hostname_from_url(existing_data[CONF_HOST]),
+ ): str,
+ vol.Required(
+ CONF_PROTOCOL,
+ default=_get_protocol_from_url(existing_data[CONF_HOST]),
+ ): vol.In(ALL_PROTOCOLS),
+ }
+ ),
+ errors=errors,
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -249,12 +329,14 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(user_input, self.unique_id)
- except TimeoutError:
+ except TimeoutError as ex:
+ _LOGGER.debug("Connection timed out: %s", ex)
return {"base": "cannot_connect"}, None
- except InvalidAuth:
+ except InvalidAuth as ex:
+ _LOGGER.debug("Invalid auth for %s: %s", user_input.get(CONF_HOST), ex)
return {CONF_PASSWORD: "invalid_auth"}, None
except Exception:
- _LOGGER.exception("Unexpected exception")
+ _LOGGER.exception("Unexpected error validating input")
return {"base": "unknown"}, None
if importing:
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index 328672edbed..5a3f476a65d 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -14,7 +14,11 @@ from elkm1_lib.util import pretty_const
from elkm1_lib.zones import Zone
import voluptuous as vol
-from homeassistant.components.sensor import SensorEntity
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
from homeassistant.const import EntityCategory, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -32,6 +36,16 @@ SERVICE_SENSOR_ZONE_BYPASS = "sensor_zone_bypass"
SERVICE_SENSOR_ZONE_TRIGGER = "sensor_zone_trigger"
UNDEFINED_TEMPERATURE = -40
+_DEVICE_CLASS_MAP: dict[ZoneType, SensorDeviceClass] = {
+ ZoneType.TEMPERATURE: SensorDeviceClass.TEMPERATURE,
+ ZoneType.ANALOG_ZONE: SensorDeviceClass.VOLTAGE,
+}
+
+_STATE_CLASS_MAP: dict[ZoneType, SensorStateClass] = {
+ ZoneType.TEMPERATURE: SensorStateClass.MEASUREMENT,
+ ZoneType.ANALOG_ZONE: SensorStateClass.MEASUREMENT,
+}
+
ELK_SET_COUNTER_SERVICE_SCHEMA: VolDictType = {
vol.Required(ATTR_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 65535))
}
@@ -248,6 +262,16 @@ class ElkZone(ElkSensor):
return self._temperature_unit
return None
+ @property
+ def device_class(self) -> SensorDeviceClass | None:
+ """Return the device class of the sensor."""
+ return _DEVICE_CLASS_MAP.get(self._element.definition)
+
+ @property
+ def state_class(self) -> SensorStateClass | None:
+ """Return the state class of the sensor."""
+ return _STATE_CLASS_MAP.get(self._element.definition)
+
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
index 19967612b0f..400a7197f41 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -17,8 +17,8 @@
"address": "The IP address or domain or serial port if connecting via serial.",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "prefix": "A unique prefix (leave blank if you only have one ElkM1).",
- "temperature_unit": "The temperature unit ElkM1 uses."
+ "prefix": "A unique prefix (leave blank if you only have one Elk-M1).",
+ "temperature_unit": "The temperature unit Elk-M1 uses."
}
},
"discovered_connection": {
@@ -30,6 +30,16 @@
"password": "[%key:common::config_flow::data::password%]",
"temperature_unit": "[%key:component::elkm1::config::step::manual_connection::data::temperature_unit%]"
}
+ },
+ "reconfigure": {
+ "title": "Reconfigure Elk-M1 Control",
+ "description": "[%key:component::elkm1::config::step::manual_connection::description%]",
+ "data": {
+ "protocol": "[%key:component::elkm1::config::step::manual_connection::data::protocol%]",
+ "address": "[%key:component::elkm1::config::step::manual_connection::data::address%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
}
},
"error": {
@@ -42,8 +52,10 @@
"unknown": "[%key:common::config_flow::error::unknown%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "already_configured": "An ElkM1 with this prefix is already configured",
- "address_already_configured": "An ElkM1 with this address is already configured"
+ "already_configured": "An Elk-M1 with this prefix is already configured",
+ "address_already_configured": "An Elk-M1 with this address is already configured",
+ "reconfigure_successful": "Successfully reconfigured Elk-M1 integration",
+ "unique_id_mismatch": "Reconfigure should be used for the same device not a new one"
}
},
"services": {
@@ -69,7 +81,7 @@
},
"alarm_arm_home_instant": {
"name": "Alarm arm home instant",
- "description": "Arms the ElkM1 in home instant mode.",
+ "description": "Arms the Elk-M1 in home instant mode.",
"fields": {
"code": {
"name": "Code",
@@ -79,7 +91,7 @@
},
"alarm_arm_night_instant": {
"name": "Alarm arm night instant",
- "description": "Arms the ElkM1 in night instant mode.",
+ "description": "Arms the Elk-M1 in night instant mode.",
"fields": {
"code": {
"name": "Code",
@@ -89,7 +101,7 @@
},
"alarm_arm_vacation": {
"name": "Alarm arm vacation",
- "description": "Arms the ElkM1 in vacation mode.",
+ "description": "Arms the Elk-M1 in vacation mode.",
"fields": {
"code": {
"name": "Code",
@@ -99,7 +111,7 @@
},
"alarm_display_message": {
"name": "Alarm display message",
- "description": "Displays a message on all of the ElkM1 keypads for an area.",
+ "description": "Displays a message on all of the Elk-M1 keypads for an area.",
"fields": {
"clear": {
"name": "Clear",
@@ -135,7 +147,7 @@
},
"speak_phrase": {
"name": "Speak phrase",
- "description": "Speaks a phrase. See list of phrases in ElkM1 ASCII Protocol documentation.",
+ "description": "Speaks a phrase. See list of phrases in Elk-M1 ASCII Protocol documentation.",
"fields": {
"number": {
"name": "Phrase number",
@@ -149,7 +161,7 @@
},
"speak_word": {
"name": "Speak word",
- "description": "Speaks a word. See list of words in ElkM1 ASCII Protocol documentation.",
+ "description": "Speaks a word. See list of words in Elk-M1 ASCII Protocol documentation.",
"fields": {
"number": {
"name": "Word number",
diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json
index bc86e6e9bab..d21da453976 100644
--- a/homeassistant/components/emoncms/manifest.json
+++ b/homeassistant/components/emoncms/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"iot_class": "local_polling",
- "requirements": ["pyemoncms==0.1.2"]
+ "requirements": ["pyemoncms==0.1.3"]
}
diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json
index 3c8c445b766..29a061f9229 100644
--- a/homeassistant/components/emoncms_history/manifest.json
+++ b/homeassistant/components/emoncms_history/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
"iot_class": "local_polling",
"quality_scale": "legacy",
- "requirements": ["pyemoncms==0.1.2"]
+ "requirements": ["pyemoncms==0.1.3"]
}
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index 1105e6f6b86..9da5d0adfd5 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -48,6 +48,7 @@ VALID_ENERGY_UNITS_GAS = {
UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
*VALID_ENERGY_UNITS,
}
VALID_VOLUME_UNITS_WATER: set[str] = {
@@ -56,6 +57,7 @@ VALID_VOLUME_UNITS_WATER: set[str] = {
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.GALLONS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
}
_LOGGER = logging.getLogger(__name__)
@@ -76,7 +78,7 @@ class SourceAdapter:
"""Adapter to allow sources and their flows to be used as sensors."""
source_type: Literal["grid", "gas", "water"]
- flow_type: Literal["flow_from", "flow_to", None]
+ flow_type: Literal["flow_from", "flow_to"] | None
stat_energy_key: Literal["stat_energy_from", "stat_energy_to"]
total_money_key: Literal["stat_cost", "stat_compensation"]
name_suffix: str
diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py
index 3590ee9e848..6c11c2b068c 100644
--- a/homeassistant/components/energy/validate.py
+++ b/homeassistant/components/energy/validate.py
@@ -42,6 +42,7 @@ GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
),
}
GAS_PRICE_UNITS = tuple(
@@ -57,6 +58,7 @@ WATER_USAGE_UNITS: dict[str, tuple[UnitOfVolume, ...]] = {
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.GALLONS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
),
}
WATER_PRICE_UNITS = tuple(
diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json
index 2faba47e126..bd79d591f6b 100644
--- a/homeassistant/components/enocean/manifest.json
+++ b/homeassistant/components/enocean/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "enocean",
"name": "EnOcean",
- "codeowners": ["@bdurrer"],
+ "codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enocean",
"iot_class": "local_push",
diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py
index 93244068feb..6487830675f 100644
--- a/homeassistant/components/enphase_envoy/diagnostics.py
+++ b/homeassistant/components/enphase_envoy/diagnostics.py
@@ -118,7 +118,6 @@ async def async_get_config_entry_diagnostics(
device_dict.pop("_cache", None)
# This can be removed when suggested_area is removed from DeviceEntry
device_dict.pop("_suggested_area")
- device_dict.pop("is_new", None)
device_entities.append({"device": device_dict, "entities": entities})
# remove envoy serial
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index 0e1e89cf1e3..a0cdda7b2b7 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
- "requirements": ["pyenphase==2.3.0"],
+ "requirements": ["pyenphase==2.4.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py
index 8e72457f4a7..85b21da1dd5 100644
--- a/homeassistant/components/ephember/climate.py
+++ b/homeassistant/components/ephember/climate.py
@@ -3,14 +3,15 @@
from __future__ import annotations
from datetime import timedelta
+from enum import IntEnum
import logging
from typing import Any
from pyephember2.pyephember2 import (
EphEmber,
ZoneMode,
+ boiler_state,
zone_current_temperature,
- zone_is_active,
zone_is_hotwater,
zone_mode,
zone_name,
@@ -53,6 +54,15 @@ EPH_TO_HA_STATE = {
"OFF": HVACMode.OFF,
}
+
+class EPHBoilerStates(IntEnum):
+ """Boiler states for a zone given by the api."""
+
+ FIXME = 0
+ OFF = 1
+ ON = 2
+
+
HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()}
@@ -123,7 +133,7 @@ class EphEmberThermostat(ClimateEntity):
@property
def hvac_action(self) -> HVACAction:
"""Return current HVAC action."""
- if zone_is_active(self._zone):
+ if boiler_state(self._zone) == EPHBoilerStates.ON:
return HVACAction.HEATING
return HVACAction.IDLE
diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py
index b4be3cf5ee9..957d17a55d4 100644
--- a/homeassistant/components/eq3btsmart/__init__.py
+++ b/homeassistant/components/eq3btsmart/__init__.py
@@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
f"[{eq3_config.mac_address}] Device could not be found"
)
- thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
+ thermostat = Thermostat(device)
entry.runtime_data = Eq3ConfigEntryData(
eq3_config=eq3_config, thermostat=thermostat
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
index 472384fdf7d..c95ef6b1c63 100644
--- a/homeassistant/components/eq3btsmart/manifest.json
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
- "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"]
+ "requirements": ["eq3btsmart==2.3.0"]
}
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index f621c74642b..cb1a3d10c97 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -51,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
client_info=CLIENT_INFO,
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
+ timezone=hass.config.time_zone,
)
domain_data = DomainData.get(hass)
diff --git a/homeassistant/components/esphome/analytics.py b/homeassistant/components/esphome/analytics.py
new file mode 100644
index 00000000000..d801bfeb31f
--- /dev/null
+++ b/homeassistant/components/esphome/analytics.py
@@ -0,0 +1,11 @@
+"""Analytics platform."""
+
+from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
+from homeassistant.core import HomeAssistant
+
+
+async def async_modify_analytics(
+ hass: HomeAssistant, analytics_input: AnalyticsInput
+) -> AnalyticsModifications:
+ """Modify the analytics."""
+ return AnalyticsModifications(remove=True)
diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py
index adddacd3998..aa565fa6107 100644
--- a/homeassistant/components/esphome/assist_satellite.py
+++ b/homeassistant/components/esphome/assist_satellite.py
@@ -127,27 +127,39 @@ class EsphomeAssistSatellite(
available_wake_words=[], active_wake_words=[], max_active_wake_words=1
)
- @property
- def pipeline_entity_id(self) -> str | None:
- """Return the entity ID of the pipeline to use for the next conversation."""
- assert self._entry_data.device_info is not None
+ self._active_pipeline_index = 0
+
+ def _get_entity_id(self, suffix: str) -> str | None:
+ """Return the entity id for pipeline select, etc."""
+ if self._entry_data.device_info is None:
+ return None
+
ent_reg = er.async_get(self.hass)
return ent_reg.async_get_entity_id(
Platform.SELECT,
DOMAIN,
- f"{self._entry_data.device_info.mac_address}-pipeline",
+ f"{self._entry_data.device_info.mac_address}-{suffix}",
)
+ @property
+ def pipeline_entity_id(self) -> str | None:
+ """Return the entity ID of the primary pipeline to use for the next conversation."""
+ return self.get_pipeline_entity(self._active_pipeline_index)
+
+ def get_pipeline_entity(self, index: int) -> str | None:
+ """Return the entity ID of a pipeline by index."""
+ id_suffix = "" if index < 1 else f"_{index + 1}"
+ return self._get_entity_id(f"pipeline{id_suffix}")
+
+ def get_wake_word_entity(self, index: int) -> str | None:
+ """Return the entity ID of a wake word by index."""
+ id_suffix = "" if index < 1 else f"_{index + 1}"
+ return self._get_entity_id(f"wake_word{id_suffix}")
+
@property
def vad_sensitivity_entity_id(self) -> str | None:
"""Return the entity ID of the VAD sensitivity to use for the next conversation."""
- assert self._entry_data.device_info is not None
- ent_reg = er.async_get(self.hass)
- return ent_reg.async_get_entity_id(
- Platform.SELECT,
- DOMAIN,
- f"{self._entry_data.device_info.mac_address}-vad_sensitivity",
- )
+ return self._get_entity_id("vad_sensitivity")
@callback
def async_get_configuration(
@@ -235,6 +247,7 @@ class EsphomeAssistSatellite(
)
)
+ assert self._attr_supported_features is not None
if feature_flags & VoiceAssistantFeature.ANNOUNCE:
# Device supports announcements
self._attr_supported_features |= (
@@ -257,8 +270,8 @@ class EsphomeAssistSatellite(
# Update wake word select when config is updated
self.async_on_remove(
- self._entry_data.async_register_assist_satellite_set_wake_word_callback(
- self.async_set_wake_word
+ self._entry_data.async_register_assist_satellite_set_wake_words_callback(
+ self.async_set_wake_words
)
)
@@ -482,8 +495,31 @@ class EsphomeAssistSatellite(
# ANNOUNCEMENT format from media player
self._update_tts_format()
- # Run the pipeline
- _LOGGER.debug("Running pipeline from %s to %s", start_stage, end_stage)
+ # Run the appropriate pipeline.
+ self._active_pipeline_index = 0
+
+ maybe_pipeline_index = 0
+ while True:
+ if not (ww_entity_id := self.get_wake_word_entity(maybe_pipeline_index)):
+ break
+
+ if not (ww_state := self.hass.states.get(ww_entity_id)):
+ continue
+
+ if ww_state.state == wake_word_phrase:
+ # First match
+ self._active_pipeline_index = maybe_pipeline_index
+ break
+
+ # Try next wake word select
+ maybe_pipeline_index += 1
+
+ _LOGGER.debug(
+ "Running pipeline %s from %s to %s",
+ self._active_pipeline_index + 1,
+ start_stage,
+ end_stage,
+ )
self._pipeline_task = self.config_entry.async_create_background_task(
self.hass,
self.async_accept_pipeline_from_satellite(
@@ -514,6 +550,7 @@ class EsphomeAssistSatellite(
def handle_pipeline_finished(self) -> None:
"""Handle when pipeline has finished running."""
self._stop_udp_server()
+ self._active_pipeline_index = 0
_LOGGER.debug("Pipeline finished")
def handle_timer_event(
@@ -542,15 +579,15 @@ class EsphomeAssistSatellite(
self.tts_response_finished()
@callback
- def async_set_wake_word(self, wake_word_id: str) -> None:
- """Set active wake word and update config on satellite."""
- self._satellite_config.active_wake_words = [wake_word_id]
+ def async_set_wake_words(self, wake_word_ids: list[str]) -> None:
+ """Set active wake words and update config on satellite."""
+ self._satellite_config.active_wake_words = wake_word_ids
self.config_entry.async_create_background_task(
self.hass,
self.async_set_configuration(self._satellite_config),
"esphome_voice_assistant_set_config",
)
- _LOGGER.debug("Setting active wake word: %s", wake_word_id)
+ _LOGGER.debug("Setting active wake word(s): %s", wake_word_ids)
def _update_tts_format(self) -> None:
"""Update the TTS format from the first media player."""
diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py
index 927ea87e0bf..8c4e0603191 100644
--- a/homeassistant/components/esphome/climate.py
+++ b/homeassistant/components/esphome/climate.py
@@ -55,7 +55,9 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import callback
+from homeassistant.exceptions import ServiceValidationError
+from .const import DOMAIN
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
@@ -161,11 +163,9 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
self._attr_max_temp = static_info.visual_max_temperature
self._attr_min_humidity = round(static_info.visual_min_humidity)
self._attr_max_humidity = round(static_info.visual_max_humidity)
- features = ClimateEntityFeature(0)
+ features = ClimateEntityFeature.TARGET_TEMPERATURE
if static_info.supports_two_point_target_temperature:
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
- else:
- features |= ClimateEntityFeature.TARGET_TEMPERATURE
if static_info.supports_target_humidity:
features |= ClimateEntityFeature.TARGET_HUMIDITY
if self.preset_modes:
@@ -253,18 +253,31 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
@esphome_float_state_property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- return self._state.target_temperature
+ if (
+ not self._static_info.supports_two_point_target_temperature
+ and self.hvac_mode != HVACMode.AUTO
+ ):
+ return self._state.target_temperature
+ if self.hvac_mode == HVACMode.HEAT:
+ return self._state.target_temperature_low
+ if self.hvac_mode == HVACMode.COOL:
+ return self._state.target_temperature_high
+ return None
@property
@esphome_float_state_property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
+ if self.hvac_mode == HVACMode.AUTO:
+ return None
return self._state.target_temperature_low
@property
@esphome_float_state_property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
+ if self.hvac_mode == HVACMode.AUTO:
+ return None
return self._state.target_temperature_high
@property
@@ -282,7 +295,27 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
cast(HVACMode, kwargs[ATTR_HVAC_MODE])
)
if ATTR_TEMPERATURE in kwargs:
- data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
+ if not self._static_info.supports_two_point_target_temperature:
+ data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
+ else:
+ hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode
+ if hvac_mode == HVACMode.HEAT:
+ data["target_temperature_low"] = kwargs[ATTR_TEMPERATURE]
+ elif hvac_mode == HVACMode.COOL:
+ data["target_temperature_high"] = kwargs[ATTR_TEMPERATURE]
+ else:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="action_call_failed",
+ translation_placeholders={
+ "call_name": "climate.set_temperature",
+ "device_name": self._static_info.name,
+ "error": (
+ f"Setting target_temperature is only supported in "
+ f"{HVACMode.HEAT} or {HVACMode.COOL} modes"
+ ),
+ },
+ )
if ATTR_TARGET_TEMP_LOW in kwargs:
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
if ATTR_TARGET_TEMP_HIGH in kwargs:
diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py
index 4efb0e494ef..fc81dfdbc43 100644
--- a/homeassistant/components/esphome/config_flow.py
+++ b/homeassistant/components/esphome/config_flow.py
@@ -22,19 +22,23 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
+ SOURCE_ESPHOME,
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
+ FlowType,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
-from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.data_entry_flow import AbortFlow, FlowResultType
+from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -57,6 +61,7 @@ from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
+ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
@@ -74,6 +79,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize flow."""
self._host: str | None = None
+ self._connected_address: str | None = None
self.__name: str | None = None
self._port: int | None = None
self._password: str | None = None
@@ -137,7 +143,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = ""
return await self._async_authenticate_or_add()
+ if error == ERROR_INVALID_PASSWORD_AUTH or (
+ error is None and self._device_info and self._device_info.uses_password
+ ):
+ return await self.async_step_authenticate()
+
if error is None and entry_data.get(CONF_NOISE_PSK):
+ # Device was configured with encryption but now connects without it.
+ # Check if it's the same device before offering to remove encryption.
+ if self._reauth_entry.unique_id and self._device_mac:
+ expected_mac = format_mac(self._reauth_entry.unique_id)
+ actual_mac = format_mac(self._device_mac)
+ if expected_mac != actual_mac:
+ # Different device at the same IP - do not offer to remove encryption
+ return self._async_abort_wrong_device(
+ self._reauth_entry, expected_mac, actual_mac
+ )
return await self.async_step_reauth_encryption_removed_confirm()
return await self.async_step_reauth_confirm()
@@ -482,18 +503,55 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id
)
- return self._async_create_entry()
+ return await self._async_create_entry()
- @callback
- def _async_create_entry(self) -> ConfigFlowResult:
+ async def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._name is not None
+ assert self._device_info is not None
+
+ # Check if Z-Wave capabilities are present and start discovery flow
+ next_flow_id: str | None = None
+ if self._device_info.zwave_proxy_feature_flags:
+ assert self._connected_address is not None
+ assert self._port is not None
+
+ # Start Z-Wave discovery flow and get the flow ID
+ zwave_result = await self.hass.config_entries.flow.async_init(
+ "zwave_js",
+ context={
+ "source": SOURCE_ESPHOME,
+ "discovery_key": discovery_flow.DiscoveryKey(
+ domain=DOMAIN,
+ key=self._device_info.mac_address,
+ version=1,
+ ),
+ },
+ data=ESPHomeServiceInfo(
+ name=self._device_info.name,
+ zwave_home_id=self._device_info.zwave_home_id or None,
+ ip_address=self._connected_address,
+ port=self._port,
+ noise_psk=self._noise_psk,
+ ),
+ )
+ if zwave_result["type"] in (
+ FlowResultType.ABORT,
+ FlowResultType.CREATE_ENTRY,
+ ):
+ _LOGGER.debug(
+ "Unable to continue created Z-Wave JS config flow: %s", zwave_result
+ )
+ else:
+ next_flow_id = zwave_result["flow_id"]
+
return self.async_create_entry(
title=self._name,
data=self._async_make_config_data(),
options={
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
},
+ next_flow=(FlowType.CONFIG_FLOW, next_flow_id) if next_flow_id else None,
)
@callback
@@ -508,6 +566,28 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_DEVICE_NAME: self._device_name,
}
+ @callback
+ def _async_abort_wrong_device(
+ self, entry: ConfigEntry, expected_mac: str, actual_mac: str
+ ) -> ConfigFlowResult:
+ """Abort flow because a different device was found at the IP address."""
+ assert self._host is not None
+ assert self._device_name is not None
+ if self.source == SOURCE_RECONFIGURE:
+ reason = "reconfigure_unique_id_changed"
+ else:
+ reason = "reauth_unique_id_changed"
+ return self.async_abort(
+ reason=reason,
+ description_placeholders={
+ "name": entry.data.get(CONF_DEVICE_NAME, entry.title),
+ "host": self._host,
+ "expected_mac": expected_mac,
+ "unexpected_mac": actual_mac,
+ "unexpected_device_name": self._device_name,
+ },
+ )
+
async def _async_validated_connection(self) -> ConfigFlowResult:
"""Handle validated connection."""
if self.source == SOURCE_RECONFIGURE:
@@ -518,7 +598,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
- return self._async_create_entry()
+ return await self._async_create_entry()
async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
"""Handle reauth validated connection."""
@@ -539,17 +619,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Reauth was triggered a while ago, and since than
# a new device resides at the same IP address.
assert self._device_name is not None
- return self.async_abort(
- reason="reauth_unique_id_changed",
- description_placeholders={
- "name": self._reauth_entry.data.get(
- CONF_DEVICE_NAME, self._reauth_entry.title
- ),
- "host": self._host,
- "expected_mac": format_mac(self._reauth_entry.unique_id),
- "unexpected_mac": format_mac(self.unique_id),
- "unexpected_device_name": self._device_name,
- },
+ return self._async_abort_wrong_device(
+ self._reauth_entry,
+ format_mac(self._reauth_entry.unique_id),
+ format_mac(self.unique_id),
)
async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
@@ -589,17 +662,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = self._reconfig_entry
return await self.async_step_name_conflict()
- return self.async_abort(
- reason="reconfigure_unique_id_changed",
- description_placeholders={
- "name": self._reconfig_entry.data.get(
- CONF_DEVICE_NAME, self._reconfig_entry.title
- ),
- "host": self._host,
- "expected_mac": format_mac(self._reconfig_entry.unique_id),
- "unexpected_mac": format_mac(self.unique_id),
- "unexpected_device_name": self._device_name,
- },
+ return self._async_abort_wrong_device(
+ self._reconfig_entry,
+ format_mac(self._reconfig_entry.unique_id),
+ format_mac(self.unique_id),
)
async def async_step_encryption_key(
@@ -672,13 +738,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
cli = APIClient(
host,
port or DEFAULT_PORT,
- "",
+ self._password or "",
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
)
try:
await cli.connect()
self._device_info = await cli.device_info()
+ self._connected_address = cli.connected_address
+ except InvalidAuthAPIError:
+ return ERROR_INVALID_PASSWORD_AUTH
except RequiresEncryptionAPIError:
return ERROR_REQUIRES_ENCRYPTION_KEY
except InvalidEncryptionKeyAPIError as ex:
diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py
index 385c88d6eb9..86688ebb8a6 100644
--- a/homeassistant/components/esphome/const.py
+++ b/homeassistant/components/esphome/const.py
@@ -25,3 +25,5 @@ PROJECT_URLS = {
# ESPHome always uses .0 for the changelog URL
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
+
+NO_WAKE_WORD: Final[str] = "no_wake_word"
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index eddd4d523c9..f329d8ba11a 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -49,11 +49,13 @@ from aioesphomeapi import (
from aioesphomeapi.model import ButtonInfo
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
+from homeassistant import config_entries
from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import discovery_flow, entity_registry as er
+from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.storage import Store
from .const import DOMAIN
@@ -177,9 +179,10 @@ class RuntimeEntryData:
assist_satellite_config_update_callbacks: list[
Callable[[AssistSatelliteConfiguration], None]
] = field(default_factory=list)
- assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
- default_factory=list
+ assist_satellite_set_wake_words_callbacks: list[Callable[[list[str]], None]] = (
+ field(default_factory=list)
)
+ assist_satellite_wake_words: dict[int, str] = field(default_factory=dict)
device_id_to_name: dict[int, str] = field(default_factory=dict)
entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field(
default_factory=dict
@@ -467,7 +470,7 @@ class RuntimeEntryData:
@callback
def async_on_connect(
- self, device_info: DeviceInfo, api_version: APIVersion
+ self, hass: HomeAssistant, device_info: DeviceInfo, api_version: APIVersion
) -> None:
"""Call when the entry has been connected."""
self.available = True
@@ -483,6 +486,29 @@ class RuntimeEntryData:
# be marked as unavailable or not.
self.expected_disconnect = True
+ if not device_info.zwave_proxy_feature_flags:
+ return
+
+ assert self.client.connected_address
+
+ discovery_flow.async_create_flow(
+ hass,
+ "zwave_js",
+ {"source": config_entries.SOURCE_ESPHOME},
+ ESPHomeServiceInfo(
+ name=device_info.name,
+ zwave_home_id=device_info.zwave_home_id or None,
+ ip_address=self.client.connected_address,
+ port=self.client.port,
+ noise_psk=self.client.noise_psk,
+ ),
+ discovery_key=discovery_flow.DiscoveryKey(
+ domain=DOMAIN,
+ key=device_info.mac_address,
+ version=1,
+ ),
+ )
+
@callback
def async_register_assist_satellite_config_updated_callback(
self,
@@ -501,19 +527,28 @@ class RuntimeEntryData:
callback_(config)
@callback
- def async_register_assist_satellite_set_wake_word_callback(
+ def async_register_assist_satellite_set_wake_words_callback(
self,
- callback_: Callable[[str], None],
+ callback_: Callable[[list[str]], None],
) -> CALLBACK_TYPE:
"""Register to receive callbacks when the Assist satellite's wake word is set."""
- self.assist_satellite_set_wake_word_callbacks.append(callback_)
- return partial(self.assist_satellite_set_wake_word_callbacks.remove, callback_)
+ self.assist_satellite_set_wake_words_callbacks.append(callback_)
+ return partial(self.assist_satellite_set_wake_words_callbacks.remove, callback_)
@callback
- def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None:
- """Notify listeners that the Assist satellite wake word has been set."""
- for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
- callback_(wake_word_id)
+ def async_assist_satellite_set_wake_word(
+ self, wake_word_index: int, wake_word_id: str | None
+ ) -> None:
+ """Notify listeners that the Assist satellite wake words have been set."""
+ if wake_word_id:
+ self.assist_satellite_wake_words[wake_word_index] = wake_word_id
+ else:
+ self.assist_satellite_wake_words.pop(wake_word_index, None)
+
+ wake_word_ids = list(self.assist_satellite_wake_words.values())
+
+ for callback_ in self.assist_satellite_set_wake_words_callbacks.copy():
+ callback_(wake_word_ids)
@callback
def async_register_entity_removal_callback(
diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json
index fc0595b028e..f4ac1872f5f 100644
--- a/homeassistant/components/esphome/icons.json
+++ b/homeassistant/components/esphome/icons.json
@@ -9,11 +9,17 @@
"pipeline": {
"default": "mdi:filter-outline"
},
+ "pipeline_2": {
+ "default": "mdi:filter-outline"
+ },
"vad_sensitivity": {
"default": "mdi:volume-high"
},
"wake_word": {
"default": "mdi:microphone"
+ },
+ "wake_word_2": {
+ "default": "mdi:microphone"
}
}
}
diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py
index d7e65470499..958dcde9f30 100644
--- a/homeassistant/components/esphome/lock.py
+++ b/homeassistant/components/esphome/lock.py
@@ -40,8 +40,10 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity):
@property
@esphome_state_property
- def is_locked(self) -> bool:
+ def is_locked(self) -> bool | None:
"""Return true if the lock is locked."""
+ if self._state.state is LockState.NONE:
+ return None
return self._state.state is LockState.LOCKED
@property
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index 74b429cdfa1..239dfe5662a 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -372,6 +372,9 @@ class ESPHomeManager:
"""Subscribe to states and list entities on successful API login."""
try:
await self._on_connect()
+ except InvalidAuthAPIError as err:
+ _LOGGER.warning("Authentication failed for %s: %s", self.host, err)
+ await self._start_reauth_and_disconnect()
except APIConnectionError as err:
_LOGGER.warning(
"Error getting setting up connection for %s: %s", self.host, err
@@ -505,7 +508,7 @@ class ESPHomeManager:
api_version = cli.api_version
assert api_version is not None, "API version must be set"
- entry_data.async_on_connect(device_info, api_version)
+ entry_data.async_on_connect(hass, device_info, api_version)
await self._handle_dynamic_encryption_key(device_info)
@@ -641,7 +644,14 @@ class ESPHomeManager:
if self.reconnect_logic:
await self.reconnect_logic.stop()
return
+ await self._start_reauth_and_disconnect()
+
+ async def _start_reauth_and_disconnect(self) -> None:
+ """Start reauth flow and stop reconnection attempts."""
self.entry.async_start_reauth(self.hass)
+ await self.cli.disconnect()
+ if self.reconnect_logic:
+ await self.reconnect_logic.stop()
async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
@@ -1063,7 +1073,7 @@ def _async_register_service(
service_name,
{
"description": (
- f"Calls the service {service.name} of the node {device_info.name}"
+ f"Performs the action {service.name} of the node {device_info.name}"
),
"fields": fields,
},
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index ffb02571742..9b38b83f335 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
- "aioesphomeapi==39.0.0",
+ "aioesphomeapi==41.12.0",
"esphome-dashboard-api==1.3.0",
- "bleak-esphome==3.1.0"
+ "bleak-esphome==3.4.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py
index 3834e4251ea..65494e06a36 100644
--- a/homeassistant/components/esphome/select.py
+++ b/homeassistant/components/esphome/select.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from dataclasses import replace
+
from aioesphomeapi import EntityInfo, SelectInfo, SelectState
from homeassistant.components.assist_pipeline.select import (
@@ -15,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import restore_state
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from .const import DOMAIN, NO_WAKE_WORD
from .entity import (
EsphomeAssistEntity,
EsphomeEntity,
@@ -50,9 +52,11 @@ async def async_setup_entry(
):
async_add_entities(
[
- EsphomeAssistPipelineSelect(hass, entry_data),
+ EsphomeAssistPipelineSelect(hass, entry_data, index=0),
+ EsphomeAssistPipelineSelect(hass, entry_data, index=1),
EsphomeVadSensitivitySelect(hass, entry_data),
- EsphomeAssistSatelliteWakeWordSelect(entry_data),
+ EsphomeAssistSatelliteWakeWordSelect(entry_data, index=0),
+ EsphomeAssistSatelliteWakeWordSelect(entry_data, index=1),
]
)
@@ -84,10 +88,14 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity):
class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect):
"""Pipeline selector for esphome devices."""
- def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry_data: RuntimeEntryData, index: int = 0
+ ) -> None:
"""Initialize a pipeline selector."""
EsphomeAssistEntity.__init__(self, entry_data)
- AssistPipelineSelect.__init__(self, hass, DOMAIN, self._device_info.mac_address)
+ AssistPipelineSelect.__init__(
+ self, hass, DOMAIN, self._device_info.mac_address, index=index
+ )
class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect):
@@ -109,28 +117,47 @@ class EsphomeAssistSatelliteWakeWordSelect(
translation_key="wake_word",
entity_category=EntityCategory.CONFIG,
)
- _attr_current_option: str | None = None
- _attr_options: list[str] = []
- def __init__(self, entry_data: RuntimeEntryData) -> None:
+ _attr_current_option: str | None = None
+ _attr_options: list[str] = [NO_WAKE_WORD]
+
+ def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
"""Initialize a wake word selector."""
+ if index < 1:
+ # Keep compatibility
+ key_suffix = ""
+ placeholder = ""
+ else:
+ key_suffix = f"_{index + 1}"
+ placeholder = f" {index + 1}"
+
+ self.entity_description = replace(
+ self.entity_description,
+ key=f"wake_word{key_suffix}",
+ translation_placeholders={"index": placeholder},
+ )
+
EsphomeAssistEntity.__init__(self, entry_data)
unique_id_prefix = self._device_info.mac_address
- self._attr_unique_id = f"{unique_id_prefix}-wake_word"
+ self._attr_unique_id = f"{unique_id_prefix}-{self.entity_description.key}"
# name -> id
self._wake_words: dict[str, str] = {}
+ self._wake_word_index = index
@property
def available(self) -> bool:
"""Return if entity is available."""
- return bool(self._attr_options)
+ return len(self._attr_options) > 1 # more than just NO_WAKE_WORD
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
+ if last_state := await self.async_get_last_state():
+ self._attr_current_option = last_state.state
+
# Update options when config is updated
self.async_on_remove(
self._entry_data.async_register_assist_satellite_config_updated_callback(
@@ -140,33 +167,64 @@ class EsphomeAssistSatelliteWakeWordSelect(
async def async_select_option(self, option: str) -> None:
"""Select an option."""
- if wake_word_id := self._wake_words.get(option):
- # _attr_current_option will be updated on
- # async_satellite_config_updated after the device sets the wake
- # word.
- self._entry_data.async_assist_satellite_set_wake_word(wake_word_id)
+ self._attr_current_option = option
+ self.async_write_ha_state()
+
+ wake_word_id = self._wake_words.get(option)
+ self._entry_data.async_assist_satellite_set_wake_word(
+ self._wake_word_index, wake_word_id
+ )
def async_satellite_config_updated(
self, config: AssistSatelliteConfiguration
) -> None:
"""Update options with available wake words."""
if (not config.available_wake_words) or (config.max_active_wake_words < 1):
- self._attr_current_option = None
+ # No wake words
self._wake_words.clear()
+ self._attr_current_option = NO_WAKE_WORD
+ self._attr_options = [NO_WAKE_WORD]
+ self._entry_data.assist_satellite_wake_words.pop(
+ self._wake_word_index, None
+ )
self.async_write_ha_state()
return
self._wake_words = {w.wake_word: w.id for w in config.available_wake_words}
- self._attr_options = sorted(self._wake_words)
+ self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)]
- if config.active_wake_words:
- # Select first active wake word
- wake_word_id = config.active_wake_words[0]
- for wake_word in config.available_wake_words:
- if wake_word.id == wake_word_id:
- self._attr_current_option = wake_word.wake_word
- else:
- # Select first available wake word
- self._attr_current_option = config.available_wake_words[0].wake_word
+ option = self._attr_current_option
+ if (
+ (self._wake_word_index == 0)
+ and (len(config.active_wake_words) == 1)
+ and (option in (None, NO_WAKE_WORD))
+ ):
+ option = next(
+ (
+ wake_word
+ for wake_word, wake_word_id in self._wake_words.items()
+ if wake_word_id == config.active_wake_words[0]
+ ),
+ None,
+ )
+
+ if (
+ (option is None)
+ or ((wake_word_id := self._wake_words.get(option)) is None)
+ or (wake_word_id not in config.active_wake_words)
+ ):
+ option = NO_WAKE_WORD
+
+ self._attr_current_option = option
self.async_write_ha_state()
+
+ # Keep entry data in sync
+ if wake_word_id := self._wake_words.get(option):
+ self._entry_data.assist_satellite_wake_words[self._wake_word_index] = (
+ wake_word_id
+ )
+ else:
+ self._entry_data.assist_satellite_wake_words.pop(
+ self._wake_word_index, None
+ )
diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json
index eab88e8df95..c14bc1e6707 100644
--- a/homeassistant/components/esphome/strings.json
+++ b/homeassistant/components/esphome/strings.json
@@ -12,7 +12,7 @@
"mqtt_missing_mac": "Missing MAC address in MQTT properties.",
"mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties.",
- "mqtt_missing_payload": "Missing MQTT Payload.",
+ "mqtt_missing_payload": "Missing MQTT payload.",
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).",
@@ -91,7 +91,7 @@
"subscribe_logs": "Subscribe to logs from the device."
},
"data_description": {
- "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.",
+ "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
@@ -119,8 +119,9 @@
}
},
"wake_word": {
- "name": "Wake word",
+ "name": "Wake word{index}",
"state": {
+ "no_wake_word": "No wake word",
"okay_nabu": "Okay Nabu"
}
}
@@ -153,7 +154,7 @@
"description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme."
},
"api_password_deprecated": {
- "title": "API Password deprecated on {name}",
+ "title": "API password deprecated on {name}",
"description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue."
},
"service_calls_not_allowed": {
@@ -192,10 +193,10 @@
"message": "Error communicating with the device {device_name}: {error}"
},
"error_compiling": {
- "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information."
+ "message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
},
"error_uploading": {
- "message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information."
+ "message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
},
"ota_in_progress": {
"message": "An OTA (Over-The-Air) update is already in progress for {configuration}."
diff --git a/homeassistant/components/evil_genius_labs/manifest.json b/homeassistant/components/evil_genius_labs/manifest.json
index 42d8d354ac8..9f096961f2f 100644
--- a/homeassistant/components/evil_genius_labs/manifest.json
+++ b/homeassistant/components/evil_genius_labs/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "evil_genius_labs",
"name": "Evil Genius Labs",
- "codeowners": ["@balloob"],
+ "codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/evil_genius_labs",
"iot_class": "local_polling",
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index 9dce352df30..7b7f8225063 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -162,12 +162,12 @@ def setup_service_functions(
It appears that all TCC-compatible systems support the same three zones modes.
"""
- @verify_domain_control(hass, DOMAIN)
+ @verify_domain_control(DOMAIN)
async def force_refresh(call: ServiceCall) -> None:
"""Obtain the latest state data via the vendor's RESTful API."""
await coordinator.async_refresh()
- @verify_domain_control(hass, DOMAIN)
+ @verify_domain_control(DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode."""
assert coordinator.tcs is not None # mypy
@@ -179,7 +179,7 @@ def setup_service_functions(
}
async_dispatcher_send(hass, DOMAIN, payload)
- @verify_domain_control(hass, DOMAIN)
+ @verify_domain_control(DOMAIN)
async def set_zone_override(call: ServiceCall) -> None:
"""Set the zone override (setpoint)."""
entity_id = call.data[ATTR_ENTITY_ID]
diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py
index 54614e4899a..0a76871285b 100644
--- a/homeassistant/components/ezviz/entity.py
+++ b/homeassistant/components/ezviz/entity.py
@@ -26,11 +26,14 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
super().__init__(coordinator)
self._serial = serial
self._camera_name = self.data["name"]
+
+ connections = set()
+ if mac_address := self.data["mac_address"]:
+ connections.add((CONNECTION_NETWORK_MAC, mac_address))
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
- connections={
- (CONNECTION_NETWORK_MAC, self.data["mac_address"]),
- },
+ connections=connections,
manufacturer=MANUFACTURER,
model=self.data["device_sub_category"],
name=self.data["name"],
@@ -62,11 +65,14 @@ class EzvizBaseEntity(Entity):
self._serial = serial
self.coordinator = coordinator
self._camera_name = self.data["name"]
+
+ connections = set()
+ if mac_address := self.data["mac_address"]:
+ connections.add((CONNECTION_NETWORK_MAC, mac_address))
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
- connections={
- (CONNECTION_NETWORK_MAC, self.data["mac_address"]),
- },
+ connections=connections,
manufacturer=MANUFACTURER,
model=self.data["device_sub_category"],
name=self.data["name"],
diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py
index ec631e8e5c1..c441b34b42d 100644
--- a/homeassistant/components/ezviz/sensor.py
+++ b/homeassistant/components/ezviz/sensor.py
@@ -66,26 +66,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
key="last_alarm_type_name",
translation_key="last_alarm_type_name",
),
- "Record_Mode": SensorEntityDescription(
- key="Record_Mode",
- translation_key="record_mode",
- entity_registry_enabled_default=False,
- ),
- "battery_camera_work_mode": SensorEntityDescription(
- key="battery_camera_work_mode",
- translation_key="battery_camera_work_mode",
- entity_registry_enabled_default=False,
- ),
- "powerStatus": SensorEntityDescription(
- key="powerStatus",
- translation_key="power_status",
- entity_registry_enabled_default=False,
- ),
- "OnlineStatus": SensorEntityDescription(
- key="OnlineStatus",
- translation_key="online_status",
- entity_registry_enabled_default=False,
- ),
}
@@ -96,26 +76,16 @@ async def async_setup_entry(
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
- entities: list[EzvizSensor] = []
- for camera, sensors in coordinator.data.items():
- entities.extend(
+ async_add_entities(
+ [
EzvizSensor(coordinator, camera, sensor)
- for sensor, value in sensors.items()
- if sensor in SENSOR_TYPES and value is not None
- )
-
- optionals = sensors.get("optionals", {})
- entities.extend(
- EzvizSensor(coordinator, camera, optional_key)
- for optional_key in ("powerStatus", "OnlineStatus")
- if optional_key in optionals
- )
-
- if "mode" in optionals.get("Record_Mode", {}):
- entities.append(EzvizSensor(coordinator, camera, "mode"))
-
- async_add_entities(entities)
+ for camera in coordinator.data
+ for sensor, value in coordinator.data[camera].items()
+ if sensor in SENSOR_TYPES
+ if value is not None
+ ]
+ )
class EzvizSensor(EzvizEntity, SensorEntity):
diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json
index ad8f7114407..b03a5dbc61a 100644
--- a/homeassistant/components/ezviz/strings.json
+++ b/homeassistant/components/ezviz/strings.json
@@ -147,18 +147,6 @@
},
"last_alarm_type_name": {
"name": "Last alarm type name"
- },
- "record_mode": {
- "name": "Record mode"
- },
- "battery_camera_work_mode": {
- "name": "Battery work mode"
- },
- "power_status": {
- "name": "Power status"
- },
- "online_status": {
- "name": "Online status"
}
},
"switch": {
diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json
index c4951e88c91..485d6aa4b59 100644
--- a/homeassistant/components/fan/strings.json
+++ b/homeassistant/components/fan/strings.json
@@ -14,6 +14,9 @@
"toggle": "[%key:common::device_automation::action_type::toggle%]",
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
"turn_off": "[%key:common::device_automation::action_type::turn_off%]"
+ },
+ "extra_fields": {
+ "for": "[%key:common::device_automation::extra_fields::for%]"
}
},
"entity_component": {
diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json
index 088b116d167..bd1a6f890a3 100644
--- a/homeassistant/components/feedreader/manifest.json
+++ b/homeassistant/components/feedreader/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/feedreader",
"iot_class": "cloud_polling",
"loggers": ["feedparser", "sgmllib3k"],
- "requirements": ["feedparser==6.0.11"]
+ "requirements": ["feedparser==6.0.12"]
}
diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py
index 59a08715b8e..8f49fb09775 100644
--- a/homeassistant/components/file/__init__.py
+++ b/homeassistant/components/file/__init__.py
@@ -7,11 +7,22 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
+from .services import async_register_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the file component."""
+ async_register_services(hass)
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a file component entry."""
diff --git a/homeassistant/components/file/const.py b/homeassistant/components/file/const.py
index 0fa9f8a421b..2504610bf5a 100644
--- a/homeassistant/components/file/const.py
+++ b/homeassistant/components/file/const.py
@@ -6,3 +6,7 @@ CONF_TIMESTAMP = "timestamp"
DEFAULT_NAME = "File"
FILE_ICON = "mdi:file"
+
+SERVICE_READ_FILE = "read_file"
+ATTR_FILE_NAME = "file_name"
+ATTR_FILE_ENCODING = "file_encoding"
diff --git a/homeassistant/components/file/icons.json b/homeassistant/components/file/icons.json
new file mode 100644
index 00000000000..826048974cc
--- /dev/null
+++ b/homeassistant/components/file/icons.json
@@ -0,0 +1,7 @@
+{
+ "services": {
+ "read_file": {
+ "service": "mdi:file"
+ }
+ }
+}
diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py
new file mode 100644
index 00000000000..3db7bb2c922
--- /dev/null
+++ b/homeassistant/components/file/services.py
@@ -0,0 +1,88 @@
+"""File Service calls."""
+
+from collections.abc import Callable
+import json
+
+import voluptuous as vol
+import yaml
+
+from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv
+
+from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
+
+
+def async_register_services(hass: HomeAssistant) -> None:
+ """Register services for File integration."""
+
+ if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE):
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_READ_FILE,
+ read_file,
+ schema=vol.Schema(
+ {
+ vol.Required(ATTR_FILE_NAME): cv.string,
+ vol.Required(ATTR_FILE_ENCODING): cv.string,
+ }
+ ),
+ supports_response=SupportsResponse.ONLY,
+ )
+
+
+ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = {
+ "json": (json.loads, json.JSONDecodeError),
+ "yaml": (yaml.safe_load, yaml.YAMLError),
+}
+
+
+def read_file(call: ServiceCall) -> dict:
+ """Handle read_file service call."""
+ file_name = call.data[ATTR_FILE_NAME]
+ file_encoding = call.data[ATTR_FILE_ENCODING].lower()
+
+ if not call.hass.config.is_allowed_path(file_name):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_access_to_path",
+ translation_placeholders={"filename": file_name},
+ )
+
+ if file_encoding not in ENCODING_LOADERS:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="unsupported_file_encoding",
+ translation_placeholders={
+ "filename": file_name,
+ "encoding": file_encoding,
+ },
+ )
+
+ try:
+ with open(file_name, encoding="utf-8") as file:
+ file_content = file.read()
+ except FileNotFoundError as err:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="file_not_found",
+ translation_placeholders={"filename": file_name},
+ ) from err
+ except OSError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="file_read_error",
+ translation_placeholders={"filename": file_name},
+ ) from err
+
+ loader, error_type = ENCODING_LOADERS[file_encoding]
+ try:
+ data = loader(file_content)
+ except error_type as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="file_decoding",
+ translation_placeholders={"filename": file_name, "encoding": file_encoding},
+ ) from err
+
+ return {"data": data}
diff --git a/homeassistant/components/file/services.yaml b/homeassistant/components/file/services.yaml
new file mode 100644
index 00000000000..18dafe88205
--- /dev/null
+++ b/homeassistant/components/file/services.yaml
@@ -0,0 +1,14 @@
+# Describes the format for available file services
+read_file:
+ fields:
+ file_name:
+ example: "www/my_file.json"
+ selector:
+ text:
+ file_encoding:
+ example: "JSON"
+ selector:
+ select:
+ options:
+ - "JSON"
+ - "YAML"
diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json
index 02f8c42755b..66666b3dd7d 100644
--- a/homeassistant/components/file/strings.json
+++ b/homeassistant/components/file/strings.json
@@ -64,6 +64,37 @@
},
"write_access_failed": {
"message": "Write access to {filename} failed: {exc}."
+ },
+ "no_access_to_path": {
+ "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
+ },
+ "unsupported_file_encoding": {
+ "message": "Cannot read {filename}, unsupported file encoding {encoding}."
+ },
+ "file_decoding": {
+ "message": "Cannot read file {filename} as {encoding}."
+ },
+ "file_not_found": {
+ "message": "File {filename} not found."
+ },
+ "file_read_error": {
+ "message": "Error reading {filename}."
+ }
+ },
+ "services": {
+ "read_file": {
+ "name": "Read file",
+ "description": "Reads a file and returns the contents.",
+ "fields": {
+ "file_name": {
+ "name": "File name",
+ "description": "Name of the file to read."
+ },
+ "file_encoding": {
+ "name": "File encoding",
+ "description": "Encoding of the file (JSON, YAML.)"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py
index 9a4f4913c9f..8d7a39b1280 100644
--- a/homeassistant/components/filter/__init__.py
+++ b/homeassistant/components/filter/__init__.py
@@ -10,7 +10,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Filter from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -18,8 +17,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Filter config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py
index 7bbfb9f6f0a..f974250b1e8 100644
--- a/homeassistant/components/filter/config_flow.py
+++ b/homeassistant/components/filter/config_flow.py
@@ -246,6 +246,7 @@ class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/firefly_iii/__init__.py b/homeassistant/components/firefly_iii/__init__.py
new file mode 100644
index 00000000000..6a778ae8c8a
--- /dev/null
+++ b/homeassistant/components/firefly_iii/__init__.py
@@ -0,0 +1,27 @@
+"""The Firefly III integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
+ """Set up Firefly III from a config entry."""
+
+ coordinator = FireflyDataUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py
new file mode 100644
index 00000000000..a2d06850179
--- /dev/null
+++ b/homeassistant/components/firefly_iii/config_flow.py
@@ -0,0 +1,140 @@
+"""Config flow for the Firefly III integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from pyfirefly import (
+ Firefly,
+ FireflyAuthenticationError,
+ FireflyConnectionError,
+ FireflyTimeoutError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ vol.Optional(CONF_VERIFY_SSL, default=True): bool,
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+
+
+async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
+ """Validate the user input allows us to connect."""
+
+ try:
+ client = Firefly(
+ api_url=data[CONF_URL],
+ api_key=data[CONF_API_KEY],
+ session=async_get_clientsession(hass),
+ )
+ await client.get_about()
+ except FireflyAuthenticationError:
+ raise InvalidAuth from None
+ except FireflyConnectionError as err:
+ raise CannotConnect from err
+ except FireflyTimeoutError as err:
+ raise FireflyClientTimeout from err
+
+ return True
+
+
+class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Firefly III."""
+
+ VERSION = 1
+
+ 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_URL: user_input[CONF_URL]})
+ try:
+ await _validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except FireflyClientTimeout:
+ errors["base"] = "timeout_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_URL], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth when Firefly III API authentication fails."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauth: ask for a new API key and validate."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ try:
+ await _validate_input(
+ self.hass,
+ data={
+ **reauth_entry.data,
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ },
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except FireflyClientTimeout:
+ errors["base"] = "timeout_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ errors=errors,
+ )
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class FireflyClientTimeout(HomeAssistantError):
+ """Error to indicate a timeout occurred."""
diff --git a/homeassistant/components/firefly_iii/const.py b/homeassistant/components/firefly_iii/const.py
new file mode 100644
index 00000000000..d8de96ddc5d
--- /dev/null
+++ b/homeassistant/components/firefly_iii/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Firefly III integration."""
+
+DOMAIN = "firefly_iii"
+
+MANUFACTURER = "Firefly III"
+NAME = "Firefly III"
diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py
new file mode 100644
index 00000000000..2d4ff3aaa1c
--- /dev/null
+++ b/homeassistant/components/firefly_iii/coordinator.py
@@ -0,0 +1,137 @@
+"""Data Update Coordinator for Firefly III integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+import logging
+
+from aiohttp import CookieJar
+from pyfirefly import (
+ Firefly,
+ FireflyAuthenticationError,
+ FireflyConnectionError,
+ FireflyTimeoutError,
+)
+from pyfirefly.models import Account, Bill, Budget, Category, Currency
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
+
+
+@dataclass
+class FireflyCoordinatorData:
+ """Data structure for Firefly III coordinator data."""
+
+ accounts: list[Account]
+ categories: list[Category]
+ category_details: list[Category]
+ budgets: list[Budget]
+ bills: list[Bill]
+ primary_currency: Currency
+
+
+class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
+ """Coordinator to manage data updates for Firefly III integration."""
+
+ config_entry: FireflyConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
+ self.firefly = Firefly(
+ api_url=self.config_entry.data[CONF_URL],
+ api_key=self.config_entry.data[CONF_API_KEY],
+ session=async_create_clientsession(
+ self.hass,
+ self.config_entry.data[CONF_VERIFY_SSL],
+ cookie_jar=CookieJar(unsafe=True),
+ ),
+ )
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ try:
+ await self.firefly.get_about()
+ except FireflyAuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except FireflyConnectionError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except FireflyTimeoutError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+
+ async def _async_update_data(self) -> FireflyCoordinatorData:
+ """Fetch data from Firefly III API."""
+ now = datetime.now()
+ start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ 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
+ )
+ for category in categories
+ ]
+ primary_currency = await self.firefly.get_currency_primary()
+ budgets = await self.firefly.get_budgets()
+ bills = await self.firefly.get_bills()
+ except FireflyAuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except FireflyConnectionError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except FireflyTimeoutError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+
+ return FireflyCoordinatorData(
+ accounts=accounts,
+ categories=categories,
+ category_details=category_details,
+ budgets=budgets,
+ bills=bills,
+ primary_currency=primary_currency,
+ )
diff --git a/homeassistant/components/firefly_iii/entity.py b/homeassistant/components/firefly_iii/entity.py
new file mode 100644
index 00000000000..0281065a6e7
--- /dev/null
+++ b/homeassistant/components/firefly_iii/entity.py
@@ -0,0 +1,40 @@
+"""Base entity for Firefly III integration."""
+
+from __future__ import annotations
+
+from yarl import URL
+
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import FireflyDataUpdateCoordinator
+
+
+class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
+ """Base class for Firefly III entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: FireflyDataUpdateCoordinator,
+ entity_description: EntityDescription,
+ ) -> None:
+ """Initialize a Firefly entity."""
+ super().__init__(coordinator)
+
+ self.entity_description = entity_description
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ manufacturer=MANUFACTURER,
+ configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
+ identifiers={
+ (
+ DOMAIN,
+ f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
+ )
+ },
+ )
diff --git a/homeassistant/components/firefly_iii/icons.json b/homeassistant/components/firefly_iii/icons.json
new file mode 100644
index 00000000000..9a849804192
--- /dev/null
+++ b/homeassistant/components/firefly_iii/icons.json
@@ -0,0 +1,18 @@
+{
+ "entity": {
+ "sensor": {
+ "account_type": {
+ "default": "mdi:bank",
+ "state": {
+ "expense": "mdi:cash-minus",
+ "revenue": "mdi:cash-plus",
+ "asset": "mdi:account-cash",
+ "liability": "mdi:hand-coin"
+ }
+ },
+ "category": {
+ "default": "mdi:label"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/firefly_iii/manifest.json b/homeassistant/components/firefly_iii/manifest.json
new file mode 100644
index 00000000000..59aea7c3c2f
--- /dev/null
+++ b/homeassistant/components/firefly_iii/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "firefly_iii",
+ "name": "Firefly III",
+ "codeowners": ["@erwindouna"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/firefly_iii",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["pyfirefly==0.1.6"]
+}
diff --git a/homeassistant/components/firefly_iii/quality_scale.yaml b/homeassistant/components/firefly_iii/quality_scale.yaml
new file mode 100644
index 00000000000..a985e389588
--- /dev/null
+++ b/homeassistant/components/firefly_iii/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ No explicit parallel updates are defined.
+ reauthentication-flow:
+ status: todo
+ comment: |
+ No reauthentication flow is defined. It will be done in a next iteration.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/firefly_iii/sensor.py b/homeassistant/components/firefly_iii/sensor.py
new file mode 100644
index 00000000000..e6facfb6b94
--- /dev/null
+++ b/homeassistant/components/firefly_iii/sensor.py
@@ -0,0 +1,133 @@
+"""Sensor platform for Firefly III integration."""
+
+from __future__ import annotations
+
+from pyfirefly.models import Account, Category
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.components.sensor.const import SensorDeviceClass
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
+from .entity import FireflyBaseEntity
+
+ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="account_type",
+ translation_key="account",
+ device_class=SensorDeviceClass.MONETARY,
+ state_class=SensorStateClass.TOTAL,
+ ),
+)
+
+CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="category",
+ translation_key="category",
+ device_class=SensorDeviceClass.MONETARY,
+ state_class=SensorStateClass.TOTAL,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: FireflyConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Firefly III sensor platform."""
+ coordinator = entry.runtime_data
+ entities: list[SensorEntity] = [
+ FireflyAccountEntity(
+ coordinator=coordinator,
+ entity_description=description,
+ account=account,
+ )
+ for account in coordinator.data.accounts
+ for description in ACCOUNT_SENSORS
+ ]
+
+ entities.extend(
+ FireflyCategoryEntity(
+ coordinator=coordinator,
+ entity_description=description,
+ category=category,
+ )
+ for category in coordinator.data.category_details
+ for description in CATEGORY_SENSORS
+ )
+
+ async_add_entities(entities)
+
+
+class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
+ """Entity for Firefly III account."""
+
+ def __init__(
+ self,
+ coordinator: FireflyDataUpdateCoordinator,
+ entity_description: SensorEntityDescription,
+ account: Account,
+ ) -> None:
+ """Initialize Firefly account entity."""
+ super().__init__(coordinator, entity_description)
+ self._account = account
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
+ self._attr_name = account.attributes.name
+ self._attr_native_unit_of_measurement = (
+ coordinator.data.primary_currency.attributes.code
+ )
+
+ # Account type state doesn't go well with the icons.json. Need to fix it.
+ if account.attributes.type == "expense":
+ self._attr_icon = "mdi:cash-minus"
+ elif account.attributes.type == "asset":
+ self._attr_icon = "mdi:account-cash"
+ elif account.attributes.type == "revenue":
+ self._attr_icon = "mdi:cash-plus"
+ elif account.attributes.type == "liability":
+ self._attr_icon = "mdi:hand-coin"
+ else:
+ self._attr_icon = "mdi:bank"
+
+ @property
+ def native_value(self) -> str | None:
+ """Return the state of the sensor."""
+ return self._account.attributes.current_balance
+
+
+class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
+ """Entity for Firefly III category."""
+
+ def __init__(
+ self,
+ coordinator: FireflyDataUpdateCoordinator,
+ entity_description: SensorEntityDescription,
+ category: Category,
+ ) -> None:
+ """Initialize Firefly category entity."""
+ super().__init__(coordinator, entity_description)
+ self._category = category
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
+ self._attr_name = category.attributes.name
+ self._attr_native_unit_of_measurement = (
+ coordinator.data.primary_currency.attributes.code
+ )
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the state of the sensor."""
+ spent_items = self._category.attributes.spent or []
+ earned_items = self._category.attributes.earned or []
+
+ spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
+ earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
+
+ if spent == 0 and earned == 0:
+ return None
+ return spent + earned
diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json
new file mode 100644
index 00000000000..4d5831d8d71
--- /dev/null
+++ b/homeassistant/components/firefly_iii/strings.json
@@ -0,0 +1,49 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "api_key": "[%key:common::config_flow::data::api_key%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "api_key": "The API key for authenticating with Firefly",
+ "verify_ssl": "Verify the SSL certificate of the Firefly instance"
+ },
+ "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
+ },
+ "reauth_confirm": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "The new API access token for authenticating with Firefly III"
+ },
+ "description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "An error occurred while trying to connect to the Firefly instance: {error}"
+ },
+ "invalid_auth": {
+ "message": "An error occurred while trying to authenticate: {error}"
+ },
+ "timeout_connect": {
+ "message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
+ }
+ }
+}
diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json
index 2fd49aac5ee..2691ac7ff44 100644
--- a/homeassistant/components/fjaraskupan/manifest.json
+++ b/homeassistant/components/fjaraskupan/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
- "requirements": ["fjaraskupan==2.3.2"]
+ "requirements": ["fjaraskupan==2.3.3"]
}
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
index 32c94638b1f..c645c9d08e5 100644
--- a/homeassistant/components/flexit/climate.py
+++ b/homeassistant/components/flexit/climate.py
@@ -14,13 +14,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.components.modbus import (
- CALL_TYPE_REGISTER_HOLDING,
- CALL_TYPE_REGISTER_INPUT,
- DEFAULT_HUB,
- ModbusHub,
- get_hub,
-)
+from homeassistant.components.modbus import ModbusHub, get_hub
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_NAME,
@@ -33,7 +27,13 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+# These constants are not offered by modbus, because modbus do not have
+# an official API.
+CALL_TYPE_REGISTER_HOLDING = "holding"
+CALL_TYPE_REGISTER_INPUT = "input"
CALL_TYPE_WRITE_REGISTER = "write_register"
+DEFAULT_HUB = "modbus_hub"
+
CONF_HUB = "hub"
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py
index 0506b13892b..8d4ec9ce80e 100644
--- a/homeassistant/components/flexit_bacnet/sensor.py
+++ b/homeassistant/components/flexit_bacnet/sensor.py
@@ -37,6 +37,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
FlexitSensorEntityDescription(
key="outside_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key="outside_air_temperature",
value_fn=lambda data: data.outside_air_temperature,
@@ -44,6 +45,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
FlexitSensorEntityDescription(
key="supply_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key="supply_air_temperature",
value_fn=lambda data: data.supply_air_temperature,
@@ -51,6 +53,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
FlexitSensorEntityDescription(
key="exhaust_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key="exhaust_air_temperature",
value_fn=lambda data: data.exhaust_air_temperature,
@@ -58,6 +61,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
FlexitSensorEntityDescription(
key="extract_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key="extract_air_temperature",
value_fn=lambda data: data.extract_air_temperature,
@@ -65,6 +69,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
FlexitSensorEntityDescription(
key="room_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key="room_temperature",
value_fn=lambda data: data.room_temperature,
diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py
index 0e50c8c6b03..c1e9560ba81 100644
--- a/homeassistant/components/flo/coordinator.py
+++ b/homeassistant/components/flo/coordinator.py
@@ -190,7 +190,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
return bool(
self.pending_info_alerts_count
or self.pending_warning_alerts_count
- or self.pending_warning_alerts_count
+ or self.pending_critical_alerts_count
)
@property
diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py
index 099123ccd9b..e9ad1e78cfc 100644
--- a/homeassistant/components/foscam/__init__.py
+++ b/homeassistant/components/foscam/__init__.py
@@ -16,7 +16,7 @@ from .config_flow import DEFAULT_RTSP_PORT
from .const import CONF_RTSP_PORT, LOGGER
from .coordinator import FoscamConfigEntry, FoscamCoordinator
-PLATFORMS = [Platform.CAMERA, Platform.SWITCH]
+PLATFORMS = [Platform.CAMERA, Platform.NUMBER, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
@@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo
entry.data[CONF_PASSWORD],
verbose=False,
)
+
coordinator = FoscamCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py
index 50ddd76ddb3..80b6ec96e83 100644
--- a/homeassistant/components/foscam/coordinator.py
+++ b/homeassistant/components/foscam/coordinator.py
@@ -30,10 +30,11 @@ class FoscamDeviceInfo:
is_open_white_light: bool
is_siren_alarm: bool
- volume: int
+ device_volume: int
speak_volume: int
is_turn_off_volume: bool
is_turn_off_light: bool
+ supports_speak_volume_adjustment: bool
is_open_wdr: bool | None = None
is_open_hdr: bool | None = None
@@ -118,6 +119,14 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
is_open_hdr = bool(int(mode))
+ ret_sw, software_capabilities = self.session.getSWCapabilities()
+
+ supports_speak_volume_adjustment_val = (
+ bool(int(software_capabilities.get("swCapabilities1")) & 32)
+ if ret_sw == 0
+ else False
+ )
+
return FoscamDeviceInfo(
dev_info=dev_info,
product_info=product_info,
@@ -127,10 +136,11 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_asleep=is_asleep,
is_open_white_light=is_open_white_light_val,
is_siren_alarm=is_siren_alarm_val,
- volume=volume_val,
+ device_volume=volume_val,
speak_volume=speak_volume_val,
is_turn_off_volume=is_turn_off_volume_val,
is_turn_off_light=is_turn_off_light_val,
+ supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
is_open_wdr=is_open_wdr,
is_open_hdr=is_open_hdr,
)
diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py
index 7bc983cbfaa..e9930695a75 100644
--- a/homeassistant/components/foscam/entity.py
+++ b/homeassistant/components/foscam/entity.py
@@ -13,6 +13,8 @@ from .coordinator import FoscamCoordinator
class FoscamEntity(CoordinatorEntity[FoscamCoordinator]):
"""Base entity for Foscam camera."""
+ _attr_has_entity_name = True
+
def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None:
"""Initialize the base Foscam entity."""
super().__init__(coordinator)
diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json
index 4b0b0c17c32..7dbd874b2f6 100644
--- a/homeassistant/components/foscam/icons.json
+++ b/homeassistant/components/foscam/icons.json
@@ -39,6 +39,14 @@
"wdr_switch": {
"default": "mdi:alpha-w-box"
}
+ },
+ "number": {
+ "device_volume": {
+ "default": "mdi:volume-source"
+ },
+ "speak_volume": {
+ "default": "mdi:account-voice"
+ }
}
}
}
diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json
index 87112199b0f..0b1ae5cc6f2 100644
--- a/homeassistant/components/foscam/manifest.json
+++ b/homeassistant/components/foscam/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "foscam",
"name": "Foscam",
- "codeowners": ["@krmarien"],
+ "codeowners": ["@Foscam-wangzhengyu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling",
diff --git a/homeassistant/components/foscam/number.py b/homeassistant/components/foscam/number.py
new file mode 100644
index 00000000000..e828955870d
--- /dev/null
+++ b/homeassistant/components/foscam/number.py
@@ -0,0 +1,93 @@
+"""Foscam number platform for Home Assistant."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from libpyfoscamcgi import FoscamCamera
+
+from homeassistant.components.number import NumberEntity, NumberEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import FoscamConfigEntry, FoscamCoordinator
+from .entity import FoscamEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class FoscamNumberEntityDescription(NumberEntityDescription):
+ """A custom entity description with adjustable features."""
+
+ native_value_fn: Callable[[FoscamCoordinator], int]
+ set_value_fn: Callable[[FoscamCamera, float], Any]
+ exists_fn: Callable[[FoscamCoordinator], bool]
+
+
+NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [
+ FoscamNumberEntityDescription(
+ key="device_volume",
+ translation_key="device_volume",
+ native_min_value=0,
+ native_max_value=100,
+ native_step=1,
+ native_value_fn=lambda coordinator: coordinator.data.device_volume,
+ set_value_fn=lambda session, value: session.setAudioVolume(value),
+ exists_fn=lambda _: True,
+ ),
+ FoscamNumberEntityDescription(
+ key="speak_volume",
+ translation_key="speak_volume",
+ native_min_value=0,
+ native_max_value=100,
+ native_step=1,
+ native_value_fn=lambda coordinator: coordinator.data.speak_volume,
+ set_value_fn=lambda session, value: session.setSpeakVolume(value),
+ exists_fn=lambda coordinator: coordinator.data.supports_speak_volume_adjustment,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: FoscamConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up foscam number from a config entry."""
+ coordinator = config_entry.runtime_data
+ async_add_entities(
+ FoscamVolumeNumberEntity(coordinator, description)
+ for description in NUMBER_DESCRIPTIONS
+ if description.exists_fn is None or description.exists_fn(coordinator)
+ )
+
+
+class FoscamVolumeNumberEntity(FoscamEntity, NumberEntity):
+ """Representation of a Foscam Smart AI number entity."""
+
+ entity_description: FoscamNumberEntityDescription
+
+ def __init__(
+ self,
+ coordinator: FoscamCoordinator,
+ description: FoscamNumberEntityDescription,
+ ) -> None:
+ """Initialize the data."""
+ entry_id = coordinator.config_entry.entry_id
+ super().__init__(coordinator, entry_id)
+
+ self.entity_description = description
+ self._attr_unique_id = f"{entry_id}_{description.key}"
+
+ @property
+ def native_value(self) -> float:
+ """Return the current value."""
+ return self.entity_description.native_value_fn(self.coordinator)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the value."""
+ await self.hass.async_add_executor_job(
+ self.entity_description.set_value_fn, self.coordinator.session, value
+ )
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json
index d73833b1cae..86a5ba59c0a 100644
--- a/homeassistant/components/foscam/strings.json
+++ b/homeassistant/components/foscam/strings.json
@@ -62,6 +62,14 @@
"wdr_switch": {
"name": "WDR"
}
+ },
+ "number": {
+ "device_volume": {
+ "name": "Device volume"
+ },
+ "speak_volume": {
+ "name": "Speak volume"
+ }
}
},
"services": {
diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py
index 91118a27277..8407da8edd3 100644
--- a/homeassistant/components/foscam/switch.py
+++ b/homeassistant/components/foscam/switch.py
@@ -121,7 +121,6 @@ async def async_setup_entry(
"""Set up foscam switch from a config entry."""
coordinator = config_entry.runtime_data
- await coordinator.async_config_entry_first_refresh()
entities = []
@@ -146,7 +145,6 @@ async def async_setup_entry(
class FoscamGenericSwitch(FoscamEntity, SwitchEntity):
"""A generic switch class for Foscam entities."""
- _attr_has_entity_name = True
entity_description: FoscamSwitchEntityDescription
def __init__(
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 25687f0061a..bfeef29ceba 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -151,7 +151,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
configuration_url=f"http://{self.host}",
connections={(dr.CONNECTION_NETWORK_MAC, self.mac)},
identifiers={(DOMAIN, self.unique_id)},
- manufacturer="AVM",
+ manufacturer="FRITZ!",
model=self.model,
name=self.config_entry.title,
sw_version=self.current_firmware,
@@ -471,7 +471,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, dev_mac)},
- default_manufacturer="AVM",
+ default_manufacturer="FRITZ!",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
via_device=(DOMAIN, self.unique_id),
diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py
index b9ae9edf04d..e8cad15ec3b 100644
--- a/homeassistant/components/fritz/diagnostics.py
+++ b/homeassistant/components/fritz/diagnostics.py
@@ -46,6 +46,9 @@ async def async_get_config_entry_diagnostics(
}
for _, device in avm_wrapper.devices.items()
],
+ "cpu_temperatures": await hass.async_add_executor_job(
+ avm_wrapper.fritz_status.get_cpu_temperatures
+ ),
"wan_link_properties": await avm_wrapper.async_get_wan_link_properties(),
},
}
diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py
index 49dc73bba26..eb3d5b600dd 100644
--- a/homeassistant/components/fritz/entity.py
+++ b/homeassistant/components/fritz/entity.py
@@ -125,7 +125,7 @@ class FritzBoxBaseCoordinatorEntity(CoordinatorEntity[AvmWrapper]):
configuration_url=f"http://{self.coordinator.host}",
connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)},
identifiers={(DOMAIN, self.coordinator.unique_id)},
- manufacturer="AVM",
+ manufacturer="FRITZ!",
model=self.coordinator.model,
name=self._device_name,
sw_version=self.coordinator.current_firmware,
diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json
index 27aa42d9b2c..fa5b2fc4612 100644
--- a/homeassistant/components/fritz/manifest.json
+++ b/homeassistant/components/fritz/manifest.json
@@ -1,13 +1,13 @@
{
"domain": "fritz",
- "name": "AVM FRITZ!Box Tools",
+ "name": "FRITZ!Box Tools",
"codeowners": ["@AaronDavidSchneider", "@chemelli74", "@mib1185"],
"config_flow": true,
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/fritz",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
- "requirements": ["fritzconnection[qr]==1.14.0", "xmltodict==0.13.0"],
+ "requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==0.13.0"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py
index e2df5dc6e8b..8aa48b216cb 100644
--- a/homeassistant/components/fritz/sensor.py
+++ b/homeassistant/components/fritz/sensor.py
@@ -20,6 +20,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfDataRate,
UnitOfInformation,
+ UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -142,6 +143,13 @@ def _retrieve_link_attenuation_received_state(
return status.attenuation[1] / 10 # type: ignore[no-any-return]
+def _retrieve_cpu_temperature_state(
+ status: FritzStatus, last_value: float | None
+) -> float:
+ """Return the first CPU temperature value."""
+ return status.get_cpu_temperatures()[0] # type: ignore[no-any-return]
+
+
@dataclass(frozen=True, kw_only=True)
class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription):
"""Describes Fritz sensor entity."""
@@ -274,6 +282,16 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
value_fn=_retrieve_link_attenuation_received_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
+ FritzSensorEntityDescription(
+ key="cpu_temperature",
+ translation_key="cpu_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=_retrieve_cpu_temperature_state,
+ is_suitable=lambda info: True,
+ ),
)
diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py
index bba80eadf98..43d10ee7f0a 100644
--- a/homeassistant/components/fritz/services.py
+++ b/homeassistant/components/fritz/services.py
@@ -31,11 +31,12 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
"""Call Fritz set guest wifi password service."""
- hass = service_call.hass
- target_entry_ids = await async_extract_config_entry_ids(hass, service_call)
+ target_entry_ids = await async_extract_config_entry_ids(service_call)
target_entries: list[FritzConfigEntry] = [
loaded_entry
- for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN)
+ for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
+ DOMAIN
+ )
if loaded_entry.entry_id in target_entry_ids
]
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index 45d66e9621b..5ff8dd37d33 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -28,7 +28,7 @@
}
},
"reauth_confirm": {
- "title": "Updating FRITZ!Box Tools - credentials",
+ "title": "FRITZ!Box Tools - Update credentials",
"description": "Update FRITZ!Box Tools credentials for: {host}.\n\nFRITZ!Box Tools is unable to log in to your FRITZ!Box.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
@@ -40,7 +40,7 @@
}
},
"reconfigure": {
- "title": "Updating FRITZ!Box Tools - configuration",
+ "title": "FRITZ!Box Tools - Update configuration",
"description": "Update FRITZ!Box Tools configuration for: {host}.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -174,6 +174,9 @@
},
"max_kb_s_sent": {
"name": "Max connection upload throughput"
+ },
+ "cpu_temperature": {
+ "name": "CPU temperature"
}
}
},
@@ -183,8 +186,8 @@
"description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.",
"fields": {
"device_id": {
- "name": "Fritz!Box Device",
- "description": "Select the Fritz!Box to configure."
+ "name": "FRITZ!Box device",
+ "description": "Select the FRITZ!Box to configure."
},
"password": {
"name": "[%key:common::config_flow::data::password%]",
@@ -192,7 +195,7 @@
},
"length": {
"name": "Password length",
- "description": "Length of the new password. The password will be auto-generated, if no password is set."
+ "description": "Length of the new password. It will be auto-generated if no password is set."
}
}
}
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index f1c34682cff..9c143ad9471 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -276,7 +276,7 @@ class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity)
configuration_url=f"http://{self.coordinator.host}",
connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)},
identifiers={(DOMAIN, self.coordinator.unique_id)},
- manufacturer="AVM",
+ manufacturer="FRITZ!",
model=self.coordinator.model,
name=self._device_name,
sw_version=self.coordinator.current_firmware,
diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py
index 54baa97b11a..2549b0ae81a 100644
--- a/homeassistant/components/fritzbox/button.py
+++ b/homeassistant/components/fritzbox/button.py
@@ -49,7 +49,7 @@ class FritzBoxTemplate(FritzBoxEntity, ButtonEntity):
name=self.data.name,
identifiers={(DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
- manufacturer="AVM",
+ manufacturer="FRITZ!",
model="SmartHome Template",
)
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index f6155024cbf..fae574883a3 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "fritzbox",
- "name": "AVM FRITZ!SmartHome",
+ "name": "FRITZ!SmartHome",
"codeowners": ["@mib1185", "@flabbamann"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fritzbox",
diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json
index 38bc6dc9c39..e77a7f842bc 100644
--- a/homeassistant/components/fritzbox/strings.json
+++ b/homeassistant/components/fritzbox/strings.json
@@ -3,7 +3,7 @@
"flow_title": "{name}",
"step": {
"user": {
- "description": "Enter your AVM FRITZ!Box information.",
+ "description": "Enter your FRITZ!Box information.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
@@ -42,7 +42,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"ignore_ip6_link_local": "IPv6 link local address is not supported.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
- "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices.",
+ "not_supported": "Connected to FRITZ!Box but it's unable to control Smart Home devices.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py
index ea4bf46f09c..e55d23dc91b 100644
--- a/homeassistant/components/fritzbox_callmonitor/__init__.py
+++ b/homeassistant/components/fritzbox_callmonitor/__init__.py
@@ -35,7 +35,7 @@ async def async_setup_entry(
except FritzSecurityError as ex:
_LOGGER.error(
(
- "User has insufficient permissions to access AVM FRITZ!Box settings and"
+ "User has insufficient permissions to access FRITZ!Box settings and"
" its phonebooks: %s"
),
ex,
@@ -44,7 +44,7 @@ async def async_setup_entry(
except FritzConnectionException as ex:
raise ConfigEntryAuthFailed from ex
except RequestsConnectionError as ex:
- _LOGGER.error("Unable to connect to AVM FRITZ!Box call monitor: %s", ex)
+ _LOGGER.error("Unable to connect to FRITZ!Box call monitor: %s", ex)
raise ConfigEntryNotReady from ex
config_entry.runtime_data = fritzbox_phonebook
diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py
index 60618817318..3c07d81b6fb 100644
--- a/homeassistant/components/fritzbox_callmonitor/const.py
+++ b/homeassistant/components/fritzbox_callmonitor/const.py
@@ -35,6 +35,6 @@ DEFAULT_PHONEBOOK = 0
DEFAULT_NAME = "Phone"
DOMAIN: Final = "fritzbox_callmonitor"
-MANUFACTURER: Final = "AVM"
+MANUFACTURER: Final = "FRITZ!"
PLATFORMS = [Platform.SENSOR]
diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json
index 06492647c30..da3f5385053 100644
--- a/homeassistant/components/fritzbox_callmonitor/manifest.json
+++ b/homeassistant/components/fritzbox_callmonitor/manifest.json
@@ -1,11 +1,11 @@
{
"domain": "fritzbox_callmonitor",
- "name": "AVM FRITZ!Box Call Monitor",
+ "name": "FRITZ!Box Call Monitor",
"codeowners": ["@cdce8p"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
- "requirements": ["fritzconnection[qr]==1.14.0"]
+ "requirements": ["fritzconnection[qr]==1.15.0"]
}
diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json
index 35af748ebe7..8fb843da399 100644
--- a/homeassistant/components/fritzbox_callmonitor/strings.json
+++ b/homeassistant/components/fritzbox_callmonitor/strings.json
@@ -28,7 +28,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
- "insufficient_permissions": "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks.",
+ "insufficient_permissions": "User has insufficient permissions to access FRITZ!Box settings and its phonebooks.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index ff50567257a..ebd354c5e83 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -38,6 +38,8 @@ from homeassistant.util.hass_dict import HassKey
from .storage import async_setup_frontend_storage
+_LOGGER = logging.getLogger(__name__)
+
DOMAIN = "frontend"
CONF_THEMES = "themes"
CONF_THEMES_MODES = "modes"
@@ -73,9 +75,11 @@ VALUE_NO_THEME = "none"
PRIMARY_COLOR = "primary-color"
-_LOGGER = logging.getLogger(__name__)
-EXTENDED_THEME_SCHEMA = vol.Schema(
+LEGACY_THEME_SCHEMA = vol.Any(
+ # Legacy theme scheme
+ {cv.string: cv.string},
+ # New extended schema with mode support
{
# Theme variables that apply to all modes
cv.string: cv.string,
@@ -86,28 +90,46 @@ EXTENDED_THEME_SCHEMA = vol.Schema(
vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}),
}
),
- }
+ },
)
THEME_SCHEMA = vol.Schema(
{
- cv.string: (
- vol.Any(
- # Legacy theme scheme
- {cv.string: cv.string},
- # New extended schema with mode support
- EXTENDED_THEME_SCHEMA,
- )
- )
+ # Theme variables that apply to all modes
+ cv.string: cv.string,
+ # Mode specific theme variables
+ vol.Optional(CONF_THEMES_MODES): vol.All(
+ {
+ vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}),
+ },
+ cv.has_at_least_one_key(CONF_THEMES_LIGHT, CONF_THEMES_DARK),
+ ),
}
)
+
+def _validate_themes(themes: dict) -> dict[str, Any]:
+ """Validate themes."""
+ validated_themes = {}
+ for theme_name, theme in themes.items():
+ theme_name = cv.string(theme_name)
+ LEGACY_THEME_SCHEMA(theme)
+
+ try:
+ validated_themes[theme_name] = THEME_SCHEMA(theme)
+ except vol.Invalid as err:
+ _LOGGER.error("Theme %s is invalid: %s", theme_name, err)
+
+ return validated_themes
+
+
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
- vol.Optional(CONF_THEMES): THEME_SCHEMA,
+ vol.Optional(CONF_THEMES): vol.All(dict, _validate_themes),
vol.Optional(CONF_EXTRA_MODULE_URL): vol.All(
cv.ensure_list, [cv.string]
),
@@ -430,6 +452,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass))
+ async_register_built_in_panel(hass, "light")
+ async_register_built_in_panel(hass, "security")
+ async_register_built_in_panel(hass, "climate")
+
async_register_built_in_panel(hass, "profile")
async_register_built_in_panel(
@@ -437,7 +463,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"developer-tools",
require_admin=True,
sidebar_title="developer_tools",
- sidebar_icon="hass:hammer",
+ sidebar_icon="mdi:hammer",
)
@callback
@@ -546,7 +572,7 @@ async def _async_setup_themes(
new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {})
try:
- THEME_SCHEMA(new_themes)
+ new_themes = _validate_themes(new_themes)
except vol.Invalid as err:
raise HomeAssistantError(f"Failed to reload themes: {err}") from err
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 9fc80cf0e8a..ec5832d1ec6 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20250811.1"]
+ "requirements": ["home-assistant-frontend==20251001.0"]
}
diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py
index 112ead983b9..625a965a0da 100644
--- a/homeassistant/components/fully_kiosk/button.py
+++ b/homeassistant/components/fully_kiosk/button.py
@@ -62,6 +62,12 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
press_action=lambda fully: fully.loadStartUrl(),
),
+ FullyButtonEntityDescription(
+ key="clearCache",
+ translation_key="clear_cache",
+ entity_category=EntityCategory.CONFIG,
+ press_action=lambda fully: fully.clearCache(),
+ ),
)
diff --git a/homeassistant/components/fully_kiosk/services.yaml b/homeassistant/components/fully_kiosk/services.yaml
index 7784996da9b..9cfc91295ed 100644
--- a/homeassistant/components/fully_kiosk/services.yaml
+++ b/homeassistant/components/fully_kiosk/services.yaml
@@ -1,8 +1,10 @@
load_url:
- target:
- device:
- integration: fully_kiosk
fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: fully_kiosk
url:
example: "https://home-assistant.io"
required: true
@@ -10,10 +12,12 @@ load_url:
text:
set_config:
- target:
- device:
- integration: fully_kiosk
fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: fully_kiosk
key:
example: "motionSensitivity"
required: true
@@ -26,12 +30,14 @@ set_config:
text:
start_application:
- target:
- device:
- integration: fully_kiosk
fields:
application:
example: "de.ozerov.fully"
required: true
selector:
text:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: fully_kiosk
diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json
index fdfdf7910ae..11c91c1f637 100644
--- a/homeassistant/components/fully_kiosk/strings.json
+++ b/homeassistant/components/fully_kiosk/strings.json
@@ -69,6 +69,9 @@
},
"load_start_url": {
"name": "Load start URL"
+ },
+ "clear_cache": {
+ "name": "Clear browser cache"
}
},
"image": {
@@ -144,6 +147,10 @@
"name": "Load URL",
"description": "Loads a URL on Fully Kiosk Browser.",
"fields": {
+ "device_id": {
+ "name": "Device ID",
+ "description": "The target device for this action."
+ },
"url": {
"name": "[%key:common::config_flow::data::url%]",
"description": "URL to load."
@@ -154,6 +161,10 @@
"name": "Set configuration",
"description": "Sets a configuration parameter on Fully Kiosk Browser.",
"fields": {
+ "device_id": {
+ "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
+ "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
+ },
"key": {
"name": "Key",
"description": "Configuration parameter to set."
@@ -171,6 +182,10 @@
"application": {
"name": "Application",
"description": "Package name of the application to start."
+ },
+ "device_id": {
+ "name": "[%key:component::fully_kiosk::services::load_url::fields::device_id::name%]",
+ "description": "[%key:component::fully_kiosk::services::load_url::fields::device_id::description%]"
}
}
}
diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py
index d907f863988..3da3d6cf06a 100644
--- a/homeassistant/components/generic_hygrostat/__init__.py
+++ b/homeassistant/components/generic_hygrostat/__init__.py
@@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidifer,
@@ -140,6 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_track_entity_registry_updated_event(
@@ -148,7 +150,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,))
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -186,11 +187,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py
index 449fa49b713..88cf12d741b 100644
--- a/homeassistant/components/generic_hygrostat/config_flow.py
+++ b/homeassistant/components/generic_hygrostat/config_flow.py
@@ -96,6 +96,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py
index 98cd9a02baa..177f6695bac 100644
--- a/homeassistant/components/generic_thermostat/__init__.py
+++ b/homeassistant/components/generic_thermostat/__init__.py
@@ -35,6 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_HEATER: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the heater, but
@@ -67,6 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_track_entity_registry_updated_event(
@@ -75,7 +77,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -113,11 +114,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py
index b69106597d1..c1045cad536 100644
--- a/homeassistant/components/generic_thermostat/config_flow.py
+++ b/homeassistant/components/generic_thermostat/config_flow.py
@@ -104,6 +104,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/generic_thermostat/manifest.json b/homeassistant/components/generic_thermostat/manifest.json
index 320de2aeb3e..4fe8654d947 100644
--- a/homeassistant/components/generic_thermostat/manifest.json
+++ b/homeassistant/components/generic_thermostat/manifest.json
@@ -6,5 +6,6 @@
"dependencies": ["sensor", "switch"],
"documentation": "https://www.home-assistant.io/integrations/generic_thermostat",
"integration_type": "helper",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "internal"
}
diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py
index 9ca6ecfcfe0..9bc645c6391 100644
--- a/homeassistant/components/geniushub/__init__.py
+++ b/homeassistant/components/geniushub/__init__.py
@@ -124,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) ->
def setup_service_functions(hass: HomeAssistant, broker):
"""Set up the service functions."""
- @verify_domain_control(hass, DOMAIN)
+ @verify_domain_control(DOMAIN)
async def set_zone_mode(call: ServiceCall) -> None:
"""Set the system mode."""
entity_id = call.data[ATTR_ENTITY_ID]
diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py
index 24917ab5e95..e47bb59c3d3 100644
--- a/homeassistant/components/geniushub/entity.py
+++ b/homeassistant/components/geniushub/entity.py
@@ -77,10 +77,10 @@ class GeniusDevice(GeniusEntity):
async def async_update(self) -> None:
"""Update an entity's state data."""
- if "_state" in self._device.data: # only via v3 API
- self._last_comms = dt_util.utc_from_timestamp(
- self._device.data["_state"]["lastComms"]
- )
+ if (state := self._device.data.get("_state")) and (
+ last_comms := state.get("lastComms")
+ ) is not None: # only via v3 API
+ self._last_comms = dt_util.utc_from_timestamp(last_comms)
class GeniusZone(GeniusEntity):
diff --git a/homeassistant/components/geocaching/entity.py b/homeassistant/components/geocaching/entity.py
new file mode 100644
index 00000000000..6912b65ec04
--- /dev/null
+++ b/homeassistant/components/geocaching/entity.py
@@ -0,0 +1,39 @@
+"""Sensor entities for Geocaching."""
+
+from typing import cast
+
+from geocachingapi.models import GeocachingCache
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import GeocachingDataUpdateCoordinator
+
+
+# Base class for all platforms
+class GeocachingBaseEntity(CoordinatorEntity[GeocachingDataUpdateCoordinator]):
+ """Base class for Geocaching sensors."""
+
+ _attr_has_entity_name = True
+
+
+# Base class for cache entities
+class GeocachingCacheEntity(GeocachingBaseEntity):
+ """Base class for Geocaching cache entities."""
+
+ def __init__(
+ self, coordinator: GeocachingDataUpdateCoordinator, cache: GeocachingCache
+ ) -> None:
+ """Initialize the Geocaching cache entity."""
+ super().__init__(coordinator)
+ self.cache = cache
+
+ # A device can have multiple entities, and for a cache which requires multiple entities we want to group them together.
+ # Therefore, we create a device for each cache, which holds all related entities.
+ self._attr_device_info = DeviceInfo(
+ name=f"Geocache {cache.name}",
+ identifiers={(DOMAIN, cast(str, cache.reference_code))},
+ entry_type=DeviceEntryType.SERVICE,
+ manufacturer=cache.owner.username,
+ )
diff --git a/homeassistant/components/geocaching/icons.json b/homeassistant/components/geocaching/icons.json
index 7dce199672b..1431efee62b 100644
--- a/homeassistant/components/geocaching/icons.json
+++ b/homeassistant/components/geocaching/icons.json
@@ -15,6 +15,24 @@
},
"awarded_favorite_points": {
"default": "mdi:heart"
+ },
+ "cache_name": {
+ "default": "mdi:label"
+ },
+ "cache_owner": {
+ "default": "mdi:account"
+ },
+ "cache_found_date": {
+ "default": "mdi:calendar-search"
+ },
+ "cache_found": {
+ "default": "mdi:package-variant-closed-check"
+ },
+ "cache_favorite_points": {
+ "default": "mdi:star-check"
+ },
+ "cache_hidden_date": {
+ "default": "mdi:calendar-badge"
}
}
}
diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py
index 5ceef21dfbf..daf64546f47 100644
--- a/homeassistant/components/geocaching/sensor.py
+++ b/homeassistant/components/geocaching/sensor.py
@@ -4,18 +4,25 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+import datetime
from typing import cast
-from geocachingapi.models import GeocachingStatus
+from geocachingapi.models import GeocachingCache, GeocachingStatus
-from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator
+from .entity import GeocachingBaseEntity, GeocachingCacheEntity
@dataclass(frozen=True, kw_only=True)
@@ -25,43 +32,63 @@ class GeocachingSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[GeocachingStatus], str | int | None]
-SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
+PROFILE_SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
GeocachingSensorEntityDescription(
key="find_count",
translation_key="find_count",
- native_unit_of_measurement="caches",
value_fn=lambda status: status.user.find_count,
),
GeocachingSensorEntityDescription(
key="hide_count",
translation_key="hide_count",
- native_unit_of_measurement="caches",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.hide_count,
),
GeocachingSensorEntityDescription(
key="favorite_points",
translation_key="favorite_points",
- native_unit_of_measurement="points",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.favorite_points,
),
GeocachingSensorEntityDescription(
key="souvenir_count",
translation_key="souvenir_count",
- native_unit_of_measurement="souvenirs",
value_fn=lambda status: status.user.souvenir_count,
),
GeocachingSensorEntityDescription(
key="awarded_favorite_points",
translation_key="awarded_favorite_points",
- native_unit_of_measurement="points",
entity_registry_visible_default=False,
value_fn=lambda status: status.user.awarded_favorite_points,
),
)
+@dataclass(frozen=True, kw_only=True)
+class GeocachingCacheSensorDescription(SensorEntityDescription):
+ """Define Sensor entity description class."""
+
+ value_fn: Callable[[GeocachingCache], StateType | datetime.date]
+
+
+CACHE_SENSORS: tuple[GeocachingCacheSensorDescription, ...] = (
+ GeocachingCacheSensorDescription(
+ key="found_date",
+ device_class=SensorDeviceClass.DATE,
+ value_fn=lambda cache: cache.found_date_time,
+ ),
+ GeocachingCacheSensorDescription(
+ key="favorite_points",
+ value_fn=lambda cache: cache.favorite_points,
+ ),
+ GeocachingCacheSensorDescription(
+ key="hidden_date",
+ device_class=SensorDeviceClass.DATE,
+ value_fn=lambda cache: cache.hidden_date,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: GeocachingConfigEntry,
@@ -69,14 +96,68 @@ async def async_setup_entry(
) -> None:
"""Set up a Geocaching sensor entry."""
coordinator = entry.runtime_data
- async_add_entities(
- GeocachingSensor(coordinator, description) for description in SENSORS
+
+ entities: list[Entity] = []
+
+ entities.extend(
+ GeocachingProfileSensor(coordinator, description)
+ for description in PROFILE_SENSORS
)
+ status = coordinator.data
-class GeocachingSensor(
- CoordinatorEntity[GeocachingDataUpdateCoordinator], SensorEntity
-):
+ # Add entities for tracked caches
+ entities.extend(
+ GeoEntityCacheSensorEntity(coordinator, cache, description)
+ for cache in status.tracked_caches
+ for description in CACHE_SENSORS
+ )
+
+ async_add_entities(entities)
+
+
+# Base class for a cache entity.
+# Sets the device, ID and translation settings to correctly group the entity to the correct cache device and give it the correct name.
+class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity):
+ """Base class for cache entities."""
+
+ def __init__(
+ self,
+ coordinator: GeocachingDataUpdateCoordinator,
+ cache: GeocachingCache,
+ key: str,
+ ) -> None:
+ """Initialize the Geocaching sensor."""
+ super().__init__(coordinator, cache)
+
+ self._attr_unique_id = f"{cache.reference_code}_{key}"
+
+ # The translation key determines the name of the entity as this is the lookup for the `strings.json` file.
+ self._attr_translation_key = f"cache_{key}"
+
+
+class GeoEntityCacheSensorEntity(GeoEntityBaseCache, SensorEntity):
+ """Representation of a cache sensor."""
+
+ entity_description: GeocachingCacheSensorDescription
+
+ def __init__(
+ self,
+ coordinator: GeocachingDataUpdateCoordinator,
+ cache: GeocachingCache,
+ description: GeocachingCacheSensorDescription,
+ ) -> None:
+ """Initialize the Geocaching sensor."""
+ super().__init__(coordinator, cache, description.key)
+ self.entity_description = description
+
+ @property
+ def native_value(self) -> StateType | datetime.date:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.cache)
+
+
+class GeocachingProfileSensor(GeocachingBaseEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: GeocachingSensorEntityDescription
diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json
index ca6e9d5e67f..990ebf9f0f8 100644
--- a/homeassistant/components/geocaching/strings.json
+++ b/homeassistant/components/geocaching/strings.json
@@ -33,11 +33,36 @@
},
"entity": {
"sensor": {
- "find_count": { "name": "Total finds" },
- "hide_count": { "name": "Total hides" },
- "favorite_points": { "name": "Favorite points" },
- "souvenir_count": { "name": "Total souvenirs" },
- "awarded_favorite_points": { "name": "Awarded favorite points" }
+ "find_count": {
+ "name": "Total finds",
+ "unit_of_measurement": "caches"
+ },
+ "hide_count": {
+ "name": "Total hides",
+ "unit_of_measurement": "caches"
+ },
+ "favorite_points": {
+ "name": "Favorite points",
+ "unit_of_measurement": "points"
+ },
+ "souvenir_count": {
+ "name": "Total souvenirs",
+ "unit_of_measurement": "souvenirs"
+ },
+ "awarded_favorite_points": {
+ "name": "Awarded favorite points",
+ "unit_of_measurement": "points"
+ },
+ "cache_found_date": {
+ "name": "Found date"
+ },
+ "cache_favorite_points": {
+ "name": "Favorite points",
+ "unit_of_measurement": "points"
+ },
+ "cache_hidden_date": {
+ "name": "Hidden date"
+ }
}
}
}
diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py
index aeedb847090..5ee449f3833 100644
--- a/homeassistant/components/go2rtc/__init__.py
+++ b/homeassistant/components/go2rtc/__init__.py
@@ -31,7 +31,9 @@ from homeassistant.components.camera import (
WebRTCSendMessage,
async_register_webrtc_provider,
)
+from homeassistant.components.camera.prefs import get_dynamic_camera_stream_settings
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
+from homeassistant.components.stream import Orientation
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
@@ -57,12 +59,13 @@ from .server import Server
_LOGGER = logging.getLogger(__name__)
+_FFMPEG = "ffmpeg"
_SUPPORTED_STREAMS = frozenset(
(
"bubble",
"dvrip",
"expr",
- "ffmpeg",
+ _FFMPEG,
"gopro",
"homekit",
"http",
@@ -315,6 +318,32 @@ class WebRTCProvider(CameraWebRTCProvider):
await self.teardown()
raise HomeAssistantError("Stream source is not supported by go2rtc")
+ camera_prefs = await get_dynamic_camera_stream_settings(
+ self._hass, camera.entity_id
+ )
+ if camera_prefs.orientation is not Orientation.NO_TRANSFORM:
+ # Camera orientation manually set by user
+ if not stream_source.startswith(_FFMPEG):
+ stream_source = _FFMPEG + ":" + stream_source
+ stream_source += "#video=h264#audio=copy"
+ match camera_prefs.orientation:
+ case Orientation.MIRROR:
+ stream_source += "#raw=-vf hflip"
+ case Orientation.ROTATE_180:
+ stream_source += "#rotate=180"
+ case Orientation.FLIP:
+ stream_source += "#raw=-vf vflip"
+ case Orientation.ROTATE_LEFT_AND_FLIP:
+ # Cannot use any filter when using raw one
+ stream_source += "#raw=-vf transpose=2,vflip"
+ case Orientation.ROTATE_LEFT:
+ stream_source += "#rotate=-90"
+ case Orientation.ROTATE_RIGHT_AND_FLIP:
+ # Cannot use any filter when using raw one
+ stream_source += "#raw=-vf transpose=1,vflip"
+ case Orientation.ROTATE_RIGHT:
+ stream_source += "#rotate=90"
+
streams = await self._rest_client.streams.list()
if (stream := streams.get(camera.entity_id)) is None or not any(
diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py
index 52a0320fe50..0f8be7a52e9 100644
--- a/homeassistant/components/google/__init__.py
+++ b/homeassistant/components/google/__init__.py
@@ -134,8 +134,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(async_reload_entry))
-
return True
@@ -151,12 +149,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> b
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_reload_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> None:
- """Reload config entry if the access options change."""
- if not async_entry_has_scopes(entry):
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_remove_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> None:
"""Handle removal of a local storage."""
store = LocalCalendarStore(hass, entry.entry_id)
diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py
index 15b9ed1c0d8..a998ea70d00 100644
--- a/homeassistant/components/google/config_flow.py
+++ b/homeassistant/components/google/config_flow.py
@@ -11,7 +11,11 @@ from gcal_sync.api import GoogleCalendarService
from gcal_sync.exceptions import ApiException, ApiForbiddenException
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
+from homeassistant.config_entries import (
+ SOURCE_REAUTH,
+ ConfigFlowResult,
+ OptionsFlowWithReload,
+)
from homeassistant.core import callback
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -237,12 +241,12 @@ class OAuth2FlowHandler(
@callback
def async_get_options_flow(
config_entry: GoogleConfigEntry,
- ) -> OptionsFlow:
+ ) -> OptionsFlowHandler:
"""Create an options flow."""
return OptionsFlowHandler()
-class OptionsFlowHandler(OptionsFlow):
+class OptionsFlowHandler(OptionsFlowWithReload):
"""Google Calendar options flow."""
async def async_step_init(
diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json
index 2ebd04db4b6..5831db9a0e3 100644
--- a/homeassistant/components/google_assistant_sdk/strings.json
+++ b/homeassistant/components/google_assistant_sdk/strings.json
@@ -59,7 +59,7 @@
},
"media_player": {
"name": "Media player entity",
- "description": "Name(s) of media player entities to play response on."
+ "description": "Name(s) of media player entities to play the Google Assistant's audio response on. This does not target the device for the command itself."
}
}
}
diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py
index 9d1923fd87d..3fc225ad423 100644
--- a/homeassistant/components/google_cloud/__init__.py
+++ b/homeassistant/components/google_cloud/__init__.py
@@ -12,15 +12,9 @@ PLATFORMS = [Platform.STT, Platform.TTS]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
-async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py
index fa6c952022b..34a42bd8b85 100644
--- a/homeassistant/components/google_cloud/config_flow.py
+++ b/homeassistant/components/google_cloud/config_flow.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
- OptionsFlow,
+ OptionsFlowWithReload,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import (
@@ -138,7 +138,7 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
return GoogleCloudOptionsFlowHandler()
-class GoogleCloudOptionsFlowHandler(OptionsFlow):
+class GoogleCloudOptionsFlowHandler(OptionsFlowWithReload):
"""Google Cloud options flow."""
async def async_step_init(
diff --git a/homeassistant/components/google_cloud/strings.json b/homeassistant/components/google_cloud/strings.json
index 3bf9d8c8489..4b3ffa1c012 100644
--- a/homeassistant/components/google_cloud/strings.json
+++ b/homeassistant/components/google_cloud/strings.json
@@ -25,7 +25,7 @@
"gain": "Default volume gain (in dB) of the voice",
"profiles": "Default audio profiles",
"text_type": "Default text type",
- "stt_model": "STT model"
+ "stt_model": "Speech-to-Text model"
}
}
}
diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py
index c21d42e0f3a..2a96b5e09a0 100644
--- a/homeassistant/components/google_drive/api.py
+++ b/homeassistant/components/google_drive/api.py
@@ -22,6 +22,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import config_entry_oauth2_flow
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
+_UPLOAD_MAX_RETRIES = 20
_LOGGER = logging.getLogger(__name__)
@@ -150,6 +151,7 @@ class DriveClient:
backup_metadata,
open_stream,
backup.size,
+ max_retries=_UPLOAD_MAX_RETRIES,
timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT),
)
_LOGGER.debug(
diff --git a/homeassistant/components/google_gemini/__init__.py b/homeassistant/components/google_gemini/__init__.py
deleted file mode 100644
index b0ecda85e6b..00000000000
--- a/homeassistant/components/google_gemini/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Virtual integration: Google Gemini."""
diff --git a/homeassistant/components/google_gemini/manifest.json b/homeassistant/components/google_gemini/manifest.json
deleted file mode 100644
index 783a6210a38..00000000000
--- a/homeassistant/components/google_gemini/manifest.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "domain": "google_gemini",
- "name": "Google Gemini",
- "integration_type": "virtual",
- "supported_by": "google_generative_ai_conversation"
-}
diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py
index a1fd5ea0f9b..82561d9f75e 100644
--- a/homeassistant/components/google_generative_ai_conversation/__init__.py
+++ b/homeassistant/components/google_generative_ai_conversation/__init__.py
@@ -29,8 +29,8 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
+ issue_registry as ir,
)
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -71,18 +71,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def generate_content(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
-
- if call.data[CONF_IMAGE_FILENAME]:
- # Deprecated in 2025.3, to remove in 2025.9
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_image_filename_parameter",
- breaks_in_ha_version="2025.9.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_image_filename_parameter",
- )
+ LOGGER.warning(
+ "Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. "
+ "Please use the 'ai_task.generate_data' action instead",
+ DOMAIN,
+ SERVICE_GENERATE_CONTENT,
+ )
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_generate_content",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_generate_content",
+ )
prompt_parts = [call.data[CONF_PROMPT]]
@@ -92,7 +95,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
client = config_entry.runtime_data
- files = call.data[CONF_IMAGE_FILENAME] + call.data[CONF_FILENAMES]
+ files = call.data[CONF_FILENAMES]
if files:
for filename in files:
@@ -105,7 +108,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
prompt_parts.extend(
await async_prepare_files_for_prompt(
- hass, client, [Path(filename) for filename in files]
+ hass, client, [(Path(filename), None) for filename in files]
)
)
@@ -140,9 +143,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
schema=vol.Schema(
{
vol.Required(CONF_PROMPT): cv.string,
- vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
- cv.ensure_list, [cv.string]
- ),
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
@@ -260,9 +260,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
- # Device and entity registries don't update the disabled_by flag
- # when moving a device or entity from one config entry to another,
- # so we need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to DEVICE or USER instead,
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
@@ -277,9 +277,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if device is not None:
- # Device and entity registries don't update the disabled_by flag when
- # moving a device or entity from one config entry to another, so we
- # need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to USER instead,
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py
index 4ffca835fed..1703aab1678 100644
--- a/homeassistant/components/google_generative_ai_conversation/ai_task.py
+++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py
@@ -3,6 +3,10 @@
from __future__ import annotations
from json import JSONDecodeError
+from typing import TYPE_CHECKING
+
+from google.genai.errors import APIError
+from google.genai.types import GenerateContentConfig, Part, PartUnionDict
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
@@ -11,8 +15,17 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
-from .const import LOGGER
-from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity
+from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL
+from .entity import (
+ ERROR_GETTING_RESPONSE,
+ GoogleGenerativeAILLMBaseEntity,
+ async_prepare_files_for_prompt,
+)
+
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigSubentry
+
+ from . import GoogleGenerativeAIConfigEntry
async def async_setup_entry(
@@ -37,10 +50,22 @@ class GoogleGenerativeAITaskEntity(
):
"""Google Generative AI AI Task entity."""
- _attr_supported_features = (
- ai_task.AITaskEntityFeature.GENERATE_DATA
- | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
- )
+ def __init__(
+ self,
+ entry: GoogleGenerativeAIConfigEntry,
+ subentry: ConfigSubentry,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(entry, subentry)
+ self._attr_supported_features = (
+ ai_task.AITaskEntityFeature.GENERATE_DATA
+ | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
+ )
+
+ if subentry.data.get(CONF_RECOMMENDED) or "-image" in subentry.data.get(
+ CONF_CHAT_MODEL, ""
+ ):
+ self._attr_supported_features |= ai_task.AITaskEntityFeature.GENERATE_IMAGE
async def _async_generate_data(
self,
@@ -79,3 +104,89 @@ class GoogleGenerativeAITaskEntity(
conversation_id=chat_log.conversation_id,
data=data,
)
+
+ async def _async_generate_image(
+ self,
+ task: ai_task.GenImageTask,
+ chat_log: conversation.ChatLog,
+ ) -> ai_task.GenImageTaskResult:
+ """Handle a generate image task."""
+ # Get the user prompt from the chat log
+ user_message = chat_log.content[-1]
+ assert isinstance(user_message, conversation.UserContent)
+
+ model = self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_IMAGE_MODEL)
+ prompt_parts: list[PartUnionDict] = [user_message.content]
+ if user_message.attachments:
+ prompt_parts.extend(
+ await async_prepare_files_for_prompt(
+ self.hass,
+ self._genai_client,
+ [(a.path, a.mime_type) for a in user_message.attachments],
+ )
+ )
+
+ try:
+ response = await self._genai_client.aio.models.generate_content(
+ model=model,
+ contents=prompt_parts,
+ config=GenerateContentConfig(
+ response_modalities=["TEXT", "IMAGE"],
+ ),
+ )
+ except (APIError, ValueError) as err:
+ LOGGER.error("Error generating image: %s", err)
+ raise HomeAssistantError(f"Error generating image: {err}") from err
+
+ if response.prompt_feedback:
+ raise HomeAssistantError(
+ f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}"
+ )
+
+ if (
+ not response.candidates
+ or not response.candidates[0].content
+ or not response.candidates[0].content.parts
+ ):
+ raise HomeAssistantError("Unknown error generating image")
+
+ # Parse response
+ response_text = ""
+ response_image: Part | None = None
+ for part in response.candidates[0].content.parts:
+ if (
+ part.inline_data
+ and part.inline_data.data
+ and part.inline_data.mime_type
+ and part.inline_data.mime_type.startswith("image/")
+ ):
+ if response_image is None:
+ response_image = part
+ else:
+ LOGGER.warning("Prompt generated multiple images")
+ elif isinstance(part.text, str) and not part.thought:
+ response_text += part.text
+
+ if response_image is None:
+ raise HomeAssistantError("Response did not include image")
+
+ assert response_image.inline_data is not None
+ assert response_image.inline_data.data is not None
+ assert response_image.inline_data.mime_type is not None
+
+ image_data = response_image.inline_data.data
+ mime_type = response_image.inline_data.mime_type
+
+ chat_log.async_add_assistant_content_without_tools(
+ conversation.AssistantContent(
+ agent_id=self.entity_id,
+ content=response_text,
+ )
+ )
+
+ return ai_task.GenImageTaskResult(
+ image_data=image_data,
+ conversation_id=chat_log.conversation_id,
+ mime_type=mime_type,
+ model=model.partition("/")[-1],
+ )
diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py
index ba7af5147c5..1960e2bffdc 100644
--- a/homeassistant/components/google_generative_ai_conversation/const.py
+++ b/homeassistant/components/google_generative_ai_conversation/const.py
@@ -23,6 +23,7 @@ CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
+RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image-preview"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"
diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py
index 90c144530e0..54ef22bd1a5 100644
--- a/homeassistant/components/google_generative_ai_conversation/entity.py
+++ b/homeassistant/components/google_generative_ai_conversation/entity.py
@@ -3,12 +3,13 @@
from __future__ import annotations
import asyncio
+import base64
import codecs
from collections.abc import AsyncGenerator, AsyncIterator, Callable
-from dataclasses import replace
+from dataclasses import dataclass, replace
import mimetypes
from pathlib import Path
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, Literal, cast
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -27,6 +28,7 @@ from google.genai.types import (
PartUnionDict,
SafetySetting,
Schema,
+ ThinkingConfig,
Tool,
ToolListUnion,
)
@@ -201,6 +203,30 @@ def _create_google_tool_response_content(
)
+@dataclass(slots=True)
+class PartDetails:
+ """Additional data for a content part."""
+
+ part_type: Literal["text", "thought", "function_call"]
+ """The part type for which this data is relevant for."""
+
+ index: int
+ """Start position or number of the tool."""
+
+ length: int = 0
+ """Length of the relevant data."""
+
+ thought_signature: str | None = None
+ """Base64 encoded thought signature, if available."""
+
+
+@dataclass(slots=True)
+class ContentDetails:
+ """Native data for AssistantContent."""
+
+ part_details: list[PartDetails]
+
+
def _convert_content(
content: (
conversation.UserContent
@@ -209,32 +235,91 @@ def _convert_content(
),
) -> Content:
"""Convert HA content to Google content."""
- if content.role != "assistant" or not content.tool_calls:
- role = "model" if content.role == "assistant" else content.role
+ if content.role != "assistant":
return Content(
- role=role,
- parts=[
- Part.from_text(text=content.content if content.content else ""),
- ],
+ role=content.role,
+ parts=[Part.from_text(text=content.content if content.content else "")],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
+ part_details: list[PartDetails] = (
+ content.native.part_details
+ if isinstance(content.native, ContentDetails)
+ else []
+ )
+ details: PartDetails | None = None
if content.content:
- parts.append(Part.from_text(text=content.content))
+ index = 0
+ for details in part_details:
+ if details.part_type == "text":
+ if index < details.index:
+ parts.append(
+ Part.from_text(text=content.content[index : details.index])
+ )
+ index = details.index
+ parts.append(
+ Part.from_text(
+ text=content.content[index : index + details.length],
+ )
+ )
+ if details.thought_signature:
+ parts[-1].thought_signature = base64.b64decode(
+ details.thought_signature
+ )
+ index += details.length
+ if index < len(content.content):
+ parts.append(Part.from_text(text=content.content[index:]))
+
+ if content.thinking_content:
+ index = 0
+ for details in part_details:
+ if details.part_type == "thought":
+ if index < details.index:
+ parts.append(
+ Part.from_text(
+ text=content.thinking_content[index : details.index]
+ )
+ )
+ parts[-1].thought = True
+ index = details.index
+ parts.append(
+ Part.from_text(
+ text=content.thinking_content[index : index + details.length],
+ )
+ )
+ parts[-1].thought = True
+ if details.thought_signature:
+ parts[-1].thought_signature = base64.b64decode(
+ details.thought_signature
+ )
+ index += details.length
+ if index < len(content.thinking_content):
+ parts.append(Part.from_text(text=content.thinking_content[index:]))
+ parts[-1].thought = True
if content.tool_calls:
- parts.extend(
- [
+ for index, tool_call in enumerate(content.tool_calls):
+ parts.append(
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
- for tool_call in content.tool_calls
- ]
- )
+ )
+ if details := next(
+ (
+ d
+ for d in part_details
+ if d.part_type == "function_call" and d.index == index
+ ),
+ None,
+ ):
+ if details.thought_signature:
+ parts[-1].thought_signature = base64.b64decode(
+ details.thought_signature
+ )
return Content(role="model", parts=parts)
@@ -243,14 +328,20 @@ async def _transform_stream(
result: AsyncIterator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
+ part_details: list[PartDetails] = []
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
- chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
- chunk["role"] = "assistant"
+ if part_details:
+ yield {"native": ContentDetails(part_details=part_details)}
+ part_details = []
+ yield {"role": "assistant"}
new_message = False
+ content_index = 0
+ thinking_content_index = 0
+ tool_call_index = 0
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
@@ -284,23 +375,62 @@ async def _transform_stream(
else []
)
- content = "".join([part.text for part in response_parts if part.text])
- tool_calls = []
for part in response_parts:
- if not part.function_call:
- continue
- tool_call = part.function_call
- tool_name = tool_call.name if tool_call.name else ""
- tool_args = _escape_decode(tool_call.args)
- tool_calls.append(
- llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
- )
+ chunk: conversation.AssistantContentDeltaDict = {}
- if tool_calls:
- chunk["tool_calls"] = tool_calls
+ if part.text:
+ if part.thought:
+ chunk["thinking_content"] = part.text
+ if part.thought_signature:
+ part_details.append(
+ PartDetails(
+ part_type="thought",
+ index=thinking_content_index,
+ length=len(part.text),
+ thought_signature=base64.b64encode(
+ part.thought_signature
+ ).decode("utf-8"),
+ )
+ )
+ thinking_content_index += len(part.text)
+ else:
+ chunk["content"] = part.text
+ if part.thought_signature:
+ part_details.append(
+ PartDetails(
+ part_type="text",
+ index=content_index,
+ length=len(part.text),
+ thought_signature=base64.b64encode(
+ part.thought_signature
+ ).decode("utf-8"),
+ )
+ )
+ content_index += len(part.text)
+
+ if part.function_call:
+ tool_call = part.function_call
+ tool_name = tool_call.name if tool_call.name else ""
+ tool_args = _escape_decode(tool_call.args)
+ chunk["tool_calls"] = [
+ llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
+ ]
+ if part.thought_signature:
+ part_details.append(
+ PartDetails(
+ part_type="function_call",
+ index=tool_call_index,
+ thought_signature=base64.b64encode(
+ part.thought_signature
+ ).decode("utf-8"),
+ )
+ )
+
+ yield chunk
+
+ if part_details:
+ yield {"native": ContentDetails(part_details=part_details)}
- chunk["content"] = content
- yield chunk
except (
APIError,
ValueError,
@@ -326,6 +456,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
"""Initialize the agent."""
self.entry = entry
self.subentry = subentry
+ self.default_model = default_model
self._attr_name = subentry.title
self._genai_client = entry.runtime_data
self._attr_unique_id = subentry.subentry_id
@@ -359,7 +490,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
tools = tools or []
tools.append(Tool(google_search=GoogleSearch()))
- model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+ model_name = options.get(CONF_CHAT_MODEL, self.default_model)
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for
supports_system_instruction = (
"gemma" not in model_name
@@ -448,12 +579,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
assert isinstance(user_message, conversation.UserContent)
chat_request: list[PartUnionDict] = [user_message.content]
if user_message.attachments:
- files = await async_prepare_files_for_prompt(
- self.hass,
- self._genai_client,
- [a.path for a in user_message.attachments],
+ chat_request.extend(
+ await async_prepare_files_for_prompt(
+ self.hass,
+ self._genai_client,
+ [(a.path, a.mime_type) for a in user_message.attachments],
+ )
)
- chat_request = [*chat_request, *files]
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
@@ -489,6 +621,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
+ model = options.get(CONF_CHAT_MODEL, self.default_model)
+ thinking_config: ThinkingConfig | None = None
+ if model.startswith("models/gemini-2.5") and not model.endswith(
+ ("tts", "image", "image-preview")
+ ):
+ thinking_config = ThinkingConfig(include_thoughts=True)
+
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
@@ -521,11 +660,12 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
),
),
],
+ thinking_config=thinking_config,
)
async def async_prepare_files_for_prompt(
- hass: HomeAssistant, client: Client, files: list[Path]
+ hass: HomeAssistant, client: Client, files: list[tuple[Path, str | None]]
) -> list[File]:
"""Upload files so they can be attached to a prompt.
@@ -534,10 +674,11 @@ async def async_prepare_files_for_prompt(
def upload_files() -> list[File]:
prompt_parts: list[File] = []
- for filename in files:
+ for filename, mimetype in files:
if not filename.exists():
raise HomeAssistantError(f"`{filename}` does not exist")
- mimetype = mimetypes.guess_type(filename)[0]
+ if mimetype is None:
+ mimetype = mimetypes.guess_type(filename)[0]
prompt_parts.append(
client.files.upload(
file=filename,
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index ce089440b97..829dd0d43bb 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "google_generative_ai_conversation",
- "name": "Google Generative AI",
+ "name": "Google Gemini",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@tronikos", "@ivanlh"],
"config_flow": true,
@@ -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.29.0"]
+ "requirements": ["google-genai==1.38.0"]
}
diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml
index 82190d64540..30077dec650 100644
--- a/homeassistant/components/google_generative_ai_conversation/services.yaml
+++ b/homeassistant/components/google_generative_ai_conversation/services.yaml
@@ -5,10 +5,6 @@ generate_content:
selector:
text:
multiline: true
- image_filename:
- required: false
- selector:
- object:
filenames:
required: false
selector:
diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json
index 545436da590..cc94d7de8fe 100644
--- a/homeassistant/components/google_generative_ai_conversation/strings.json
+++ b/homeassistant/components/google_generative_ai_conversation/strings.json
@@ -150,21 +150,22 @@
}
}
},
+ "issues": {
+ "deprecated_generate_content": {
+ "title": "Deprecated 'generate_content' action",
+ "description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead"
+ }
+ },
"services": {
"generate_content": {
- "name": "Generate content",
- "description": "Generate content from a prompt consisting of text and optionally images",
+ "name": "Generate content (deprecated)",
+ "description": "Generate content from a prompt consisting of text and optionally images (deprecated)",
"fields": {
"prompt": {
"name": "Prompt",
"description": "The prompt",
"example": "Describe what you see in these images"
},
- "image_filename": {
- "name": "Image filename",
- "description": "Deprecated. Use filenames instead.",
- "example": "/config/www/image.jpg"
- },
"filenames": {
"name": "Attachment filenames",
"description": "Attachments to add to the prompt (images, PDFs, etc)",
@@ -172,11 +173,5 @@
}
}
}
- },
- "issues": {
- "deprecated_image_filename_parameter": {
- "title": "Deprecated 'image_filename' parameter",
- "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' instead."
- }
}
}
diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py
index 129e04590d9..d8287ea35a1 100644
--- a/homeassistant/components/google_mail/services.py
+++ b/homeassistant/components/google_mail/services.py
@@ -51,7 +51,7 @@ async def _extract_gmail_config_entries(
) -> list[GoogleMailConfigEntry]:
return [
entry
- for entry_id in await async_extract_config_entry_ids(call.hass, call)
+ for entry_id in await async_extract_config_entry_ids(call)
if (entry := call.hass.config_entries.async_get_entry(entry_id))
and entry.domain == DOMAIN
]
diff --git a/homeassistant/components/google_mail/services.yaml b/homeassistant/components/google_mail/services.yaml
index 9ce1c41f27a..1be14b8fac2 100644
--- a/homeassistant/components/google_mail/services.yaml
+++ b/homeassistant/components/google_mail/services.yaml
@@ -1,7 +1,5 @@
set_vacation:
target:
- device:
- integration: google_mail
entity:
integration: google_mail
fields:
diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py
index c0a87e46fbc..ef6e2ef3e03 100644
--- a/homeassistant/components/google_photos/media_source.py
+++ b/homeassistant/components/google_photos/media_source.py
@@ -10,9 +10,8 @@ from typing import Self, cast
from google_photos_library_api.exceptions import GooglePhotosApiError
from google_photos_library_api.model import Album, MediaItem
-from homeassistant.components.media_player import MediaClass, MediaType
+from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
from homeassistant.components.media_source import (
- BrowseError,
BrowseMediaSource,
MediaSource,
MediaSourceItem,
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index 1a9b361bd33..1be6325d0e7 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -22,6 +22,7 @@ from google.protobuf import timestamp_pb2
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
+ SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
@@ -91,6 +92,16 @@ def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None:
return timestamp
+SENSOR_DESCRIPTIONS = [
+ SensorEntityDescription(
+ key="duration",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ )
+]
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -105,20 +116,20 @@ async def async_setup_entry(
client_options = ClientOptions(api_key=api_key)
client = RoutesAsyncClient(client_options=client_options)
- sensor = GoogleTravelTimeSensor(
- config_entry, name, api_key, origin, destination, client
- )
+ sensors = [
+ GoogleTravelTimeSensor(
+ config_entry, name, api_key, origin, destination, client, sensor_description
+ )
+ for sensor_description in SENSOR_DESCRIPTIONS
+ ]
- async_add_entities([sensor], False)
+ async_add_entities(sensors, False)
class GoogleTravelTimeSensor(SensorEntity):
"""Representation of a Google travel time sensor."""
_attr_attribution = ATTRIBUTION
- _attr_native_unit_of_measurement = UnitOfTime.MINUTES
- _attr_device_class = SensorDeviceClass.DURATION
- _attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
@@ -128,8 +139,10 @@ class GoogleTravelTimeSensor(SensorEntity):
origin: str,
destination: str,
client: RoutesAsyncClient,
+ sensor_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
+ self.entity_description = sensor_description
self._attr_name = name
self._attr_unique_id = config_entry.entry_id
self._attr_device_info = DeviceInfo(
diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py
index ee04dd81088..4315f5d5363 100644
--- a/homeassistant/components/govee_light_local/__init__.py
+++ b/homeassistant/components/govee_light_local/__init__.py
@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
from errno import EADDRINUSE
+from ipaddress import IPv4Address
import logging
from govee_local_api.controller import LISTENING_PORT
+from homeassistant.components import network
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -23,12 +25,23 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool:
"""Set up Govee light local from a config entry."""
- coordinator = GoveeLocalApiCoordinator(hass, entry)
+
+ source_ips = await async_get_source_ips(hass)
+ _LOGGER.debug("Enabled source IPs: %s", source_ips)
+
+ coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(
+ hass=hass, config_entry=entry, source_ips=source_ips
+ )
async def await_cleanup():
- cleanup_complete: asyncio.Event = coordinator.cleanup()
+ cleanup_complete_events: [asyncio.Event] = coordinator.cleanup()
with suppress(TimeoutError):
- await asyncio.wait_for(cleanup_complete.wait(), 1)
+ await asyncio.gather(
+ *[
+ asyncio.wait_for(cleanup_complete_event.wait(), 1)
+ for cleanup_complete_event in cleanup_complete_events
+ ]
+ )
entry.async_on_unload(await_cleanup)
@@ -58,3 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_get_source_ips(
+ hass: HomeAssistant,
+) -> set[str]:
+ """Get the source ips for Govee local."""
+ source_ips = await network.async_get_enabled_source_ips(hass)
+ return {
+ str(source_ip) for source_ip in source_ips if isinstance(source_ip, IPv4Address)
+ }
diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py
index da70d44688b..cd1dc00f9e0 100644
--- a/homeassistant/components/govee_light_local/config_flow.py
+++ b/homeassistant/components/govee_light_local/config_flow.py
@@ -8,10 +8,10 @@ import logging
from govee_local_api import GoveeController
-from homeassistant.components import network
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow
+from . import async_get_source_ips
from .const import (
CONF_LISTENING_PORT_DEFAULT,
CONF_MULTICAST_ADDRESS_DEFAULT,
@@ -23,15 +23,11 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
-async def _async_has_devices(hass: HomeAssistant) -> bool:
- """Return if there are devices that can be discovered."""
-
- adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP)
-
+async def _async_discover(hass: HomeAssistant, adapter_ip: str) -> bool:
controller: GoveeController = GoveeController(
loop=hass.loop,
logger=_LOGGER,
- listening_address=adapter,
+ listening_address=adapter_ip,
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
broadcast_port=CONF_TARGET_PORT_DEFAULT,
listening_port=CONF_LISTENING_PORT_DEFAULT,
@@ -41,9 +37,10 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
)
try:
+ _LOGGER.debug("Starting discovery with IP %s", adapter_ip)
await controller.start()
except OSError as ex:
- _LOGGER.error("Start failed, errno: %d", ex.errno)
+ _LOGGER.error("Start failed on IP %s, errno: %d", adapter_ip, ex.errno)
return False
try:
@@ -51,7 +48,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
while not controller.devices:
await asyncio.sleep(delay=1)
except TimeoutError:
- _LOGGER.debug("No devices found")
+ _LOGGER.debug("No devices found with IP %s", adapter_ip)
devices_count = len(controller.devices)
cleanup_complete: asyncio.Event = controller.cleanup()
@@ -61,6 +58,15 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
return devices_count > 0
+async def _async_has_devices(hass: HomeAssistant) -> bool:
+ """Return if there are devices that can be discovered."""
+
+ source_ips = await async_get_source_ips(hass)
+ results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips])
+
+ return any(results)
+
+
config_entry_flow.register_discovery_flow(
DOMAIN, "Govee light local", _async_has_devices
)
diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py
index 530ade1f743..1c2aac12f70 100644
--- a/homeassistant/components/govee_light_local/coordinator.py
+++ b/homeassistant/components/govee_light_local/coordinator.py
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
- CONF_DISCOVERY_INTERVAL_DEFAULT,
CONF_LISTENING_PORT_DEFAULT,
CONF_MULTICAST_ADDRESS_DEFAULT,
CONF_TARGET_PORT_DEFAULT,
@@ -26,10 +25,11 @@ type GoveeLocalConfigEntry = ConfigEntry[GoveeLocalApiCoordinator]
class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
"""Govee light local coordinator."""
- config_entry: GoveeLocalConfigEntry
-
def __init__(
- self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry
+ self,
+ hass: HomeAssistant,
+ config_entry: GoveeLocalConfigEntry,
+ source_ips: set[str],
) -> None:
"""Initialize my coordinator."""
super().__init__(
@@ -40,32 +40,40 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
update_interval=SCAN_INTERVAL,
)
- self._controller = GoveeController(
- loop=hass.loop,
- logger=_LOGGER,
- broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
- broadcast_port=CONF_TARGET_PORT_DEFAULT,
- listening_port=CONF_LISTENING_PORT_DEFAULT,
- discovery_enabled=True,
- discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT,
- discovered_callback=None,
- update_enabled=False,
- )
+ self._controllers: list[GoveeController] = [
+ GoveeController(
+ loop=hass.loop,
+ logger=_LOGGER,
+ listening_address=source_ip,
+ broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
+ broadcast_port=CONF_TARGET_PORT_DEFAULT,
+ listening_port=CONF_LISTENING_PORT_DEFAULT,
+ discovery_enabled=True,
+ discovery_interval=1,
+ update_enabled=False,
+ )
+ for source_ip in source_ips
+ ]
async def start(self) -> None:
"""Start the Govee coordinator."""
- await self._controller.start()
- self._controller.send_update_message()
+
+ for controller in self._controllers:
+ await controller.start()
+ controller.send_update_message()
async def set_discovery_callback(
self, callback: Callable[[GoveeDevice, bool], bool]
) -> None:
"""Set discovery callback for automatic Govee light discovery."""
- self._controller.set_device_discovered_callback(callback)
- def cleanup(self) -> asyncio.Event:
- """Stop and cleanup the cooridinator."""
- return self._controller.cleanup()
+ for controller in self._controllers:
+ controller.set_device_discovered_callback(callback)
+
+ def cleanup(self) -> list[asyncio.Event]:
+ """Stop and cleanup the coordinator."""
+
+ return [controller.cleanup() for controller in self._controllers]
async def turn_on(self, device: GoveeDevice) -> None:
"""Turn on the light."""
@@ -96,8 +104,13 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
@property
def devices(self) -> list[GoveeDevice]:
"""Return a list of discovered Govee devices."""
- return self._controller.devices
+
+ devices: list[GoveeDevice] = []
+ for controller in self._controllers:
+ devices = devices + controller.devices
+ return devices
async def _async_update_data(self) -> list[GoveeDevice]:
- self._controller.send_update_message()
- return self._controller.devices
+ for controller in self._controllers:
+ controller.send_update_message()
+ return self.devices
diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json
index 55a6b9e8578..0b108758c02 100644
--- a/homeassistant/components/govee_light_local/manifest.json
+++ b/homeassistant/components/govee_light_local/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push",
- "requirements": ["govee-local-api==2.1.0"]
+ "requirements": ["govee-local-api==2.2.0"]
}
diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py
index c48cd8529a2..f64979c6a66 100644
--- a/homeassistant/components/group/__init__.py
+++ b/homeassistant/components/group/__init__.py
@@ -141,15 +141,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["group_type"],)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py
index 88f7d9017ab..0433deab8ae 100644
--- a/homeassistant/components/group/config_flow.py
+++ b/homeassistant/components/group/config_flow.py
@@ -329,6 +329,7 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py
index 39270788780..6483e7a543c 100644
--- a/homeassistant/components/growatt_server/__init__.py
+++ b/homeassistant/components/growatt_server/__init__.py
@@ -1,14 +1,18 @@
"""The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping
+import logging
import growattServer
-from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
+from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from .const import (
+ AUTH_API_TOKEN,
+ AUTH_PASSWORD,
+ CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
DEFAULT_URL,
@@ -19,36 +23,110 @@ from .const import (
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
+_LOGGER = logging.getLogger(__name__)
-def get_device_list(
+
+def get_device_list_classic(
api: growattServer.GrowattApi, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Retrieve the device list for the selected plant."""
plant_id = config[CONF_PLANT_ID]
# Log in to api and fetch first plant if no plant id is defined.
- login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
- if (
- not login_response["success"]
- and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
- ):
- raise ConfigEntryError("Username, Password or URL may be incorrect!")
+ try:
+ login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
+ # DEBUG: Log the actual response structure
+ except Exception as ex:
+ _LOGGER.error("DEBUG - Login response: %s", login_response)
+ raise ConfigEntryError(
+ f"Error communicating with Growatt API during login: {ex}"
+ ) from ex
+
+ if not login_response.get("success"):
+ msg = login_response.get("msg", "Unknown error")
+ _LOGGER.debug("Growatt login failed: %s", msg)
+ if msg == LOGIN_INVALID_AUTH_CODE:
+ raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
+ raise ConfigEntryError(f"Growatt login failed: {msg}")
+
user_id = login_response["user"]["id"]
+
if plant_id == DEFAULT_PLANT_ID:
- plant_info = api.plant_list(user_id)
+ try:
+ plant_info = api.plant_list(user_id)
+ except Exception as ex:
+ raise ConfigEntryError(
+ f"Error communicating with Growatt API during plant list: {ex}"
+ ) from ex
+ if not plant_info or "data" not in plant_info or not plant_info["data"]:
+ raise ConfigEntryError("No plants found for this account.")
plant_id = plant_info["data"][0]["plantId"]
# Get a list of devices for specified plant to add sensors for.
- devices = api.device_list(plant_id)
+ try:
+ devices = api.device_list(plant_id)
+ except Exception as ex:
+ raise ConfigEntryError(
+ f"Error communicating with Growatt API during device list: {ex}"
+ ) from ex
+
return devices, plant_id
+def get_device_list_v1(
+ api, config: Mapping[str, str]
+) -> tuple[list[dict[str, str]], str]:
+ """Device list logic for Open API V1.
+
+ Note: Plant selection (including auto-selection if only one plant exists)
+ is handled in the config flow before this function is called. This function
+ only fetches devices for the already-selected plant_id.
+ """
+ plant_id = config[CONF_PLANT_ID]
+ try:
+ devices_dict = api.device_list(plant_id)
+ except growattServer.GrowattV1ApiError as e:
+ raise ConfigEntryError(
+ f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
+ ) from e
+ devices = devices_dict.get("devices", [])
+ # Only MIN device (type = 7) support implemented in current V1 API
+ supported_devices = [
+ {
+ "deviceSn": device.get("device_sn", ""),
+ "deviceType": "min",
+ }
+ for device in devices
+ if device.get("type") == 7
+ ]
+
+ for device in devices:
+ if device.get("type") != 7:
+ _LOGGER.warning(
+ "Device %s with type %s not supported in Open API V1, skipping",
+ device.get("device_sn", ""),
+ device.get("type"),
+ )
+ return supported_devices, plant_id
+
+
+def get_device_list(
+ api, config: Mapping[str, str], api_version: str
+) -> tuple[list[dict[str, str]], str]:
+ """Dispatch to correct device list logic based on API version."""
+ if api_version == "v1":
+ return get_device_list_v1(api, config)
+ if api_version == "classic":
+ return get_device_list_classic(api, config)
+ raise ConfigEntryError(f"Unknown API version: {api_version}")
+
+
async def async_setup_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool:
"""Set up Growatt from a config entry."""
+
config = config_entry.data
- username = config[CONF_USERNAME]
url = config.get(CONF_URL, DEFAULT_URL)
# If the URL has been deprecated then change to the default instead
@@ -58,11 +136,24 @@ async def async_setup_entry(
new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data)
- # Initialise the library with the username & a random id each time it is started
- api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
- api.server_url = url
+ # Determine API version
+ if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
+ api_version = "v1"
+ token = config[CONF_TOKEN]
+ api = growattServer.OpenApiV1(token=token)
+ elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
+ api_version = "classic"
+ username = config[CONF_USERNAME]
+ api = growattServer.GrowattApi(
+ add_random_user_id=True, agent_identifier=username
+ )
+ api.server_url = url
+ else:
+ raise ConfigEntryError("Unknown authentication type in config entry.")
- devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
+ devices, plant_id = await hass.async_add_executor_job(
+ get_device_list, api, config, api_version
+ )
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -75,7 +166,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
- if device["deviceType"] in ["inverter", "tlx", "storage", "mix"]
+ if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
}
# Perform the first refresh for the total coordinator
diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py
index e676d8fae32..4bd61beb68e 100644
--- a/homeassistant/components/growatt_server/config_flow.py
+++ b/homeassistant/components/growatt_server/config_flow.py
@@ -1,22 +1,38 @@
"""Config flow for growatt server integration."""
+import logging
from typing import Any
import growattServer
+import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_TOKEN,
+ CONF_URL,
+ CONF_USERNAME,
+)
from homeassistant.core import callback
from .const import (
+ ABORT_NO_PLANTS,
+ AUTH_API_TOKEN,
+ AUTH_PASSWORD,
+ CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_URL,
DOMAIN,
+ ERROR_CANNOT_CONNECT,
+ ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS,
)
+_LOGGER = logging.getLogger(__name__)
+
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
@@ -27,12 +43,98 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialise growatt server flow."""
- self.user_id = None
+ self.user_id: str | None = None
self.data: dict[str, Any] = {}
+ self.auth_type: str | None = None
+ self.plants: list[dict[str, Any]] = []
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the start of the config flow."""
+ return self.async_show_menu(
+ step_id="user",
+ menu_options=["password_auth", "token_auth"],
+ )
+
+ async def async_step_password_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle username/password authentication."""
+ if user_input is None:
+ return self._async_show_password_form()
+
+ self.auth_type = AUTH_PASSWORD
+
+ # Traditional username/password authentication
+ self.api = growattServer.GrowattApi(
+ add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
+ )
+ self.api.server_url = user_input[CONF_URL]
+
+ try:
+ login_response = await self.hass.async_add_executor_job(
+ self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ )
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Network error during Growatt API login: %s", ex)
+ return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
+ except (ValueError, KeyError, TypeError, AttributeError) as ex:
+ _LOGGER.error("Invalid response format during login: %s", ex)
+ return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
+
+ if (
+ not login_response["success"]
+ and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
+ ):
+ return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
+
+ self.user_id = login_response["user"]["id"]
+ self.data = user_input
+ self.data[CONF_AUTH_TYPE] = self.auth_type
+ return await self.async_step_plant()
+
+ async def async_step_token_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle API token authentication."""
+ if user_input is None:
+ return self._async_show_token_form()
+
+ self.auth_type = AUTH_API_TOKEN
+
+ # Using token authentication
+ token = user_input[CONF_TOKEN]
+ self.api = growattServer.OpenApiV1(token=token)
+
+ # Verify token by fetching plant list
+ try:
+ plant_response = await self.hass.async_add_executor_job(self.api.plant_list)
+ self.plants = plant_response.get("plants", [])
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Network error during Growatt V1 API plant list: %s", ex)
+ return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
+ except growattServer.GrowattV1ApiError as e:
+ _LOGGER.error(
+ "Growatt V1 API error: %s (Code: %s)",
+ e.error_msg or str(e),
+ getattr(e, "error_code", None),
+ )
+ return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
+ except (ValueError, KeyError, TypeError, AttributeError) as ex:
+ _LOGGER.error(
+ "Invalid response format during Growatt V1 API plant list: %s", ex
+ )
+ return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
+ self.data = user_input
+ self.data[CONF_AUTH_TYPE] = self.auth_type
+ return await self.async_step_plant()
@callback
- def _async_show_user_form(self, errors=None):
- """Show the form to the user."""
+ def _async_show_password_form(
+ self, errors: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show the username/password form to the user."""
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
@@ -42,58 +144,87 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors
+ step_id="password_auth", data_schema=data_schema, errors=errors
)
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
+ @callback
+ def _async_show_token_form(
+ self, errors: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the start of the config flow."""
- if not user_input:
- return self._async_show_user_form()
-
- # Initialise the library with the username & a random id each time it is started
- self.api = growattServer.GrowattApi(
- add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
- )
- self.api.server_url = user_input[CONF_URL]
- login_response = await self.hass.async_add_executor_job(
- self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ """Show the API token form to the user."""
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_TOKEN): str,
+ }
)
- if (
- not login_response["success"]
- and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
- ):
- return self._async_show_user_form({"base": "invalid_auth"})
- self.user_id = login_response["user"]["id"]
-
- self.data = user_input
- return await self.async_step_plant()
+ return self.async_show_form(
+ step_id="token_auth",
+ data_schema=data_schema,
+ errors=errors,
+ )
async def async_step_plant(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle adding a "plant" to Home Assistant."""
- plant_info = await self.hass.async_add_executor_job(
- self.api.plant_list, self.user_id
- )
+ if self.auth_type == AUTH_API_TOKEN:
+ # Using V1 API with token
+ if not self.plants:
+ return self.async_abort(reason=ABORT_NO_PLANTS)
- if not plant_info["data"]:
- return self.async_abort(reason="no_plants")
+ # Create dictionary of plant_id -> name
+ plant_dict = {
+ str(plant["plant_id"]): plant.get("name", "Unknown Plant")
+ for plant in self.plants
+ }
- plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]}
+ if user_input is None and len(plant_dict) > 1:
+ data_schema = vol.Schema(
+ {vol.Required(CONF_PLANT_ID): vol.In(plant_dict)}
+ )
+ return self.async_show_form(step_id="plant", data_schema=data_schema)
- if user_input is None and len(plant_info["data"]) > 1:
- data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
+ if user_input is None:
+ # Single plant => mark it as selected
+ user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]}
- return self.async_show_form(step_id="plant", data_schema=data_schema)
+ user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]]
- if user_input is None:
- # single plant => mark it as selected
- user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]}
+ else:
+ # Traditional API
+ try:
+ plant_info = await self.hass.async_add_executor_job(
+ self.api.plant_list, self.user_id
+ )
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Network error during Growatt API plant list: %s", ex)
+ return self.async_abort(reason=ERROR_CANNOT_CONNECT)
+
+ # Access plant_info["data"] - validate response structure
+ if not isinstance(plant_info, dict) or "data" not in plant_info:
+ _LOGGER.error(
+ "Invalid response format during plant list: missing 'data' key"
+ )
+ return self.async_abort(reason=ERROR_CANNOT_CONNECT)
+
+ plant_data = plant_info["data"]
+
+ if not plant_data:
+ return self.async_abort(reason=ABORT_NO_PLANTS)
+
+ plants = {plant["plantId"]: plant["plantName"] for plant in plant_data}
+
+ if user_input is None and len(plant_data) > 1:
+ data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
+ return self.async_show_form(step_id="plant", data_schema=data_schema)
+
+ if user_input is None:
+ # single plant => mark it as selected
+ user_input = {CONF_PLANT_ID: plant_data[0]["plantId"]}
+
+ user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
- user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
self._abort_if_unique_id_configured()
self.data.update(user_input)
diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py
index 4ad62aa812b..8689421b2ce 100644
--- a/homeassistant/components/growatt_server/const.py
+++ b/homeassistant/components/growatt_server/const.py
@@ -4,6 +4,16 @@ from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id"
+
+# API key support
+CONF_API_KEY = "api_key"
+
+# Auth types for config flow
+AUTH_PASSWORD = "password"
+AUTH_API_TOKEN = "api_token"
+CONF_AUTH_TYPE = "auth_type"
+DEFAULT_AUTH_TYPE = AUTH_PASSWORD
+
DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt"
@@ -29,3 +39,10 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR]
LOGIN_INVALID_AUTH_CODE = "502"
+
+# Config flow error types (also used as abort reasons)
+ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
+ERROR_INVALID_AUTH = "invalid_auth"
+
+# Config flow abort reasons
+ABORT_NO_PLANTS = "no_plants"
diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py
index a1a2fb938f0..2f00c542c13 100644
--- a/homeassistant/components/growatt_server/coordinator.py
+++ b/homeassistant/components/growatt_server/coordinator.py
@@ -1,5 +1,7 @@
"""Coordinator module for managing Growatt data fetching."""
+from __future__ import annotations
+
import datetime
import json
import logging
@@ -38,23 +40,31 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
plant_id: str,
) -> None:
"""Initialize the coordinator."""
- self.username = config_entry.data[CONF_USERNAME]
- self.password = config_entry.data[CONF_PASSWORD]
- self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
- self.api = growattServer.GrowattApi(
- add_random_user_id=True, agent_identifier=self.username
+ self.api_version = (
+ "v1" if config_entry.data.get("auth_type") == "api_token" else "classic"
)
-
- # Set server URL
- self.api.server_url = self.url
-
self.device_id = device_id
self.device_type = device_type
self.plant_id = plant_id
-
- # Initialize previous_values to store historical data
self.previous_values: dict[str, Any] = {}
+ if self.api_version == "v1":
+ self.username = None
+ self.password = None
+ self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
+ self.token = config_entry.data["token"]
+ self.api = growattServer.OpenApiV1(token=self.token)
+ elif self.api_version == "classic":
+ self.username = config_entry.data.get(CONF_USERNAME)
+ self.password = config_entry.data[CONF_PASSWORD]
+ self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
+ self.api = growattServer.GrowattApi(
+ add_random_user_id=True, agent_identifier=self.username
+ )
+ self.api.server_url = self.url
+ else:
+ raise ValueError(f"Unknown API version: {self.api_version}")
+
super().__init__(
hass,
_LOGGER,
@@ -67,21 +77,54 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Update data via library synchronously."""
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
- # Login in to the Growatt server
- self.api.login(self.username, self.password)
+ # login only required for classic API
+ if self.api_version == "classic":
+ self.api.login(self.username, self.password)
if self.device_type == "total":
- total_info = self.api.plant_info(self.device_id)
- del total_info["deviceList"]
- plant_money_text, currency = total_info["plantMoneyText"].split("/")
- total_info["plantMoneyText"] = plant_money_text
- total_info["currency"] = currency
+ if self.api_version == "v1":
+ # The V1 Plant APIs do not provide the same information as the classic plant_info() API
+ # More specifically:
+ # 1. There is no monetary information to be found, so today and lifetime money is not available
+ # 2. There is no nominal power, this is provided by inverter min_energy()
+ # This means, for the total coordinator we can only fetch and map the following:
+ # todayEnergy -> today_energy
+ # totalEnergy -> total_energy
+ # invTodayPpv -> current_power
+ total_info = self.api.plant_energy_overview(self.plant_id)
+ total_info["todayEnergy"] = total_info["today_energy"]
+ total_info["totalEnergy"] = total_info["total_energy"]
+ total_info["invTodayPpv"] = total_info["current_power"]
+ else:
+ # Classic API: use plant_info as before
+ total_info = self.api.plant_info(self.device_id)
+ del total_info["deviceList"]
+ plant_money_text, currency = total_info["plantMoneyText"].split("/")
+ total_info["plantMoneyText"] = plant_money_text
+ total_info["currency"] = currency
+ _LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info)
self.data = total_info
elif self.device_type == "inverter":
self.data = self.api.inverter_detail(self.device_id)
+ elif self.device_type == "min":
+ # Open API V1: min device
+ try:
+ min_details = self.api.min_detail(self.device_id)
+ min_settings = self.api.min_settings(self.device_id)
+ min_energy = self.api.min_energy(self.device_id)
+ except growattServer.GrowattV1ApiError as err:
+ _LOGGER.error(
+ "Error fetching min device data for %s: %s", self.device_id, err
+ )
+ raise UpdateFailed(f"Error fetching min device data: {err}") from err
+
+ min_info = {**min_details, **min_settings, **min_energy}
+ self.data = min_info
+ _LOGGER.debug("min_info for device %s: %r", self.device_id, min_info)
elif self.device_type == "tlx":
tlx_info = self.api.tlx_detail(self.device_id)
self.data = tlx_info["data"]
+ _LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info)
elif self.device_type == "storage":
storage_info_detail = self.api.storage_params(self.device_id)
storage_energy_overview = self.api.storage_energy_overview(
@@ -145,7 +188,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self.data.get("currency")
def get_data(
- self, entity_description: "GrowattSensorEntityDescription"
+ self, entity_description: GrowattSensorEntityDescription
) -> str | int | float | None:
"""Get the data."""
variable = entity_description.api_key
diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py
index 3a78f26f091..d4e76c8d868 100644
--- a/homeassistant/components/growatt_server/sensor/__init__.py
+++ b/homeassistant/components/growatt_server/sensor/__init__.py
@@ -51,7 +51,7 @@ async def async_setup_entry(
sensor_descriptions: list = []
if device_coordinator.device_type == "inverter":
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
- elif device_coordinator.device_type == "tlx":
+ elif device_coordinator.device_type in ("tlx", "min"):
sensor_descriptions = list(TLX_SENSOR_TYPES)
elif device_coordinator.device_type == "storage":
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json
index 256efea447d..fdede7fe115 100644
--- a/homeassistant/components/growatt_server/strings.json
+++ b/homeassistant/components/growatt_server/strings.json
@@ -2,26 +2,42 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account"
},
"error": {
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ "invalid_auth": "Authentication failed. Please check your credentials and try again.",
+ "cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again."
},
"step": {
+ "user": {
+ "title": "Choose authentication method",
+ "description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
+ "menu_options": {
+ "password_auth": "Username & Password",
+ "token_auth": "API Token (MIN/TLX only)"
+ }
+ },
+ "password_auth": {
+ "title": "Enter your Growatt login credentials",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "url": "[%key:common::config_flow::data::url%]"
+ }
+ },
+ "token_auth": {
+ "title": "Enter your API token",
+ "description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
+ "data": {
+ "token": "API Token"
+ }
+ },
"plant": {
"data": {
"plant_id": "Plant"
},
"title": "Select your plant"
- },
- "user": {
- "data": {
- "name": "[%key:common::config_flow::data::name%]",
- "password": "[%key:common::config_flow::data::password%]",
- "username": "[%key:common::config_flow::data::username%]",
- "url": "[%key:common::config_flow::data::url%]"
- },
- "title": "Enter your Growatt information"
}
}
},
@@ -86,7 +102,7 @@
"name": "Inverter temperature"
},
"mix_statement_of_charge": {
- "name": "Statement of charge"
+ "name": "State of charge"
},
"mix_battery_charge_today": {
"name": "Battery charged today"
@@ -425,7 +441,7 @@
"name": "Lifetime total load consumption"
},
"tlx_statement_of_charge": {
- "name": "Statement of charge (SoC)"
+ "name": "State of charge (SoC)"
},
"total_money_today": {
"name": "Total money today"
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
index 514a12d26b7..e9e2ae09350 100644
--- a/homeassistant/components/habitica/__init__.py
+++ b/homeassistant/components/habitica/__init__.py
@@ -4,9 +4,14 @@ from uuid import UUID
from habiticalib import Habitica
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -27,6 +32,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.CALENDAR,
Platform.IMAGE,
+ Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.TODO,
@@ -46,6 +52,7 @@ async def async_setup_entry(
"""Set up habitica from a config entry."""
party_added_by_this_entry: UUID | None = None
device_reg = dr.async_get(hass)
+ entity_registry = er.async_get(hass)
session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
@@ -96,6 +103,15 @@ async def async_setup_entry(
device.id, remove_config_entry_id=config_entry.entry_id
)
+ notify_entities = [
+ entry.entity_id
+ for entry in entity_registry.entities.values()
+ if entry.domain == NOTIFY_DOMAIN
+ and entry.config_entry_id == config_entry.entry_id
+ ]
+ for entity_id in notify_entities:
+ entity_registry.async_remove(entity_id)
+
hass.config_entries.async_schedule_reload(config_entry.entry_id)
coordinator.async_add_listener(_party_update_listener)
diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py
index 621c659a10c..10464acaf17 100644
--- a/homeassistant/components/habitica/binary_sensor.py
+++ b/homeassistant/components/habitica/binary_sensor.py
@@ -9,7 +9,6 @@ from enum import StrEnum
from habiticalib import ContentData, UserData
from homeassistant.components.binary_sensor import (
- BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@@ -108,7 +107,6 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
entity_description = BinarySensorEntityDescription(
key=HabiticaBinarySensor.QUEST_RUNNING,
translation_key=HabiticaBinarySensor.QUEST_RUNNING,
- device_class=BinarySensorDeviceClass.RUNNING,
)
def __init__(
@@ -123,4 +121,4 @@ class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""If the binary sensor is on."""
- return self.coordinator.data.quest.active
+ return self.coordinator.data.party.quest.active
diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py
index d7cede1db03..a32179889cf 100644
--- a/homeassistant/components/habitica/const.py
+++ b/homeassistant/components/habitica/const.py
@@ -39,6 +39,7 @@ ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item"
ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item"
ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item"
ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item"
+ATTR_COLLAPSE_CHECKLIST = "collapse_checklist"
ATTR_REMINDER = "reminder"
ATTR_REMOVE_REMINDER = "remove_reminder"
ATTR_CLEAR_REMINDER = "clear_reminder"
diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py
index d9376820b16..94de7cc1523 100644
--- a/homeassistant/components/habitica/coordinator.py
+++ b/homeassistant/components/habitica/coordinator.py
@@ -9,6 +9,7 @@ from datetime import timedelta
from io import BytesIO
import logging
from typing import Any
+from uuid import UUID
from aiohttp import ClientError
from habiticalib import (
@@ -48,6 +49,14 @@ class HabiticaData:
tasks: list[TaskData]
+@dataclass
+class HabiticaPartyData:
+ """Habitica party data."""
+
+ party: GroupData
+ members: dict[UUID, UserData]
+
+
type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator]
@@ -192,11 +201,19 @@ class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]):
return png.getvalue()
-class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]):
+class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]):
"""Habitica Party Coordinator."""
_update_interval = timedelta(minutes=15)
- async def _update_data(self) -> GroupData:
+ async def _update_data(self) -> HabiticaPartyData:
"""Fetch the latest party data."""
- return (await self.habitica.get_group()).data
+
+ return HabiticaPartyData(
+ party=(await self.habitica.get_group()).data,
+ members={
+ member.id: member
+ for member in (await self.habitica.get_group_members()).data
+ if member.id
+ },
+ )
diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py
index fa227fec334..4d82815956b 100644
--- a/homeassistant/components/habitica/entity.py
+++ b/homeassistant/components/habitica/entity.py
@@ -68,14 +68,14 @@ class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]):
super().__init__(coordinator)
if TYPE_CHECKING:
assert config_entry.unique_id
- unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}"
+ unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}"
self.entity_description = entity_description
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
model=NAME,
- name=coordinator.data.summary,
+ name=coordinator.data.party.summary,
identifiers={(DOMAIN, unique_id)},
via_device=(DOMAIN, config_entry.unique_id),
)
diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json
index 0b5d4aaa682..ee02429d371 100644
--- a/homeassistant/components/habitica/icons.json
+++ b/homeassistant/components/habitica/icons.json
@@ -174,6 +174,9 @@
},
"collected_items": {
"default": "mdi:sack"
+ },
+ "last_checkin": {
+ "default": "mdi:login-variant"
}
},
"switch": {
@@ -194,6 +197,11 @@
"quest_running": {
"default": "mdi:script-text-play"
}
+ },
+ "notify": {
+ "party_chat": {
+ "default": "mdi:forum"
+ }
}
},
"services": {
diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py
index f064074ea0a..15efc8e6667 100644
--- a/homeassistant/components/habitica/image.py
+++ b/homeassistant/components/habitica/image.py
@@ -128,7 +128,7 @@ class HabiticaPartyImage(HabiticaPartyBase, ImageEntity):
"""Return URL of image."""
return (
f"{ASSETS_URL}quest_{key}.png"
- if (key := self.coordinator.data.quest.key)
+ if (key := self.coordinator.data.party.quest.key)
else None
)
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
index 99c84f9686f..30443f1d1da 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"quality_scale": "platinum",
- "requirements": ["habiticalib==0.4.3"]
+ "requirements": ["habiticalib==0.4.5"]
}
diff --git a/homeassistant/components/habitica/notify.py b/homeassistant/components/habitica/notify.py
new file mode 100644
index 00000000000..8a29ac1d641
--- /dev/null
+++ b/homeassistant/components/habitica/notify.py
@@ -0,0 +1,202 @@
+"""Notify platform for the Habitica integration."""
+
+from __future__ import annotations
+
+from abc import abstractmethod
+from enum import StrEnum
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from aiohttp import ClientError
+from habiticalib import (
+ GroupData,
+ HabiticaException,
+ NotAuthorizedError,
+ NotFoundError,
+ TooManyRequestsError,
+ UserData,
+)
+
+from homeassistant.components.notify import (
+ DOMAIN as NOTIFY_DOMAIN,
+ NotifyEntity,
+ NotifyEntityDescription,
+)
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HABITICA_KEY
+from .const import DOMAIN
+from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
+from .entity import HabiticaBase
+
+PARALLEL_UPDATES = 10
+
+
+class HabiticaNotify(StrEnum):
+ """Habitica Notifier."""
+
+ PARTY_CHAT = "party_chat"
+ PRIVATE_MESSAGE = "private_message"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HabiticaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the notify entity platform."""
+ members_added: set[UUID] = set()
+ entity_registry = er.async_get(hass)
+
+ coordinator = config_entry.runtime_data
+
+ if party := coordinator.data.user.party.id:
+ party_coordinator = hass.data[HABITICA_KEY][party]
+ async_add_entities(
+ [HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)]
+ )
+
+ @callback
+ def add_entities() -> None:
+ nonlocal members_added
+
+ new_members = set(party_coordinator.data.members.keys()) - members_added
+ if TYPE_CHECKING:
+ assert coordinator.data.user.id
+ new_members.discard(coordinator.data.user.id)
+ if new_members:
+ async_add_entities(
+ HabiticaPrivateMessageNotifyEntity(
+ coordinator, party_coordinator.data.members[member]
+ )
+ for member in new_members
+ )
+ members_added |= new_members
+
+ delete_members = members_added - set(party_coordinator.data.members.keys())
+ for member in delete_members:
+ if entity_id := entity_registry.async_get_entity_id(
+ NOTIFY_DOMAIN,
+ DOMAIN,
+ f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
+ ):
+ entity_registry.async_remove(entity_id)
+
+ members_added.discard(member)
+
+ party_coordinator.async_add_listener(add_entities)
+ add_entities()
+
+
+class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity):
+ """Habitica base notify entity."""
+
+ def __init__(
+ self,
+ coordinator: HabiticaDataUpdateCoordinator,
+ ) -> None:
+ """Initialize a Habitica entity."""
+ super().__init__(coordinator, self.entity_description)
+
+ @abstractmethod
+ async def _send_message(self, message: str) -> None:
+ """Send a Habitica message."""
+
+ async def async_send_message(self, message: str, title: str | None = None) -> None:
+ """Send a message."""
+ try:
+ await self._send_message(message)
+ except NotAuthorizedError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="send_message_forbidden",
+ translation_placeholders={
+ **self.translation_placeholders,
+ "reason": e.error.message,
+ },
+ ) from e
+ except NotFoundError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="send_message_not_found",
+ translation_placeholders={
+ **self.translation_placeholders,
+ "reason": e.error.message,
+ },
+ ) from e
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except HabiticaException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": e.error.message},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+
+
+class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity):
+ """Representation of a Habitica party chat notify entity."""
+
+ def __init__(
+ self,
+ coordinator: HabiticaDataUpdateCoordinator,
+ party: GroupData,
+ ) -> None:
+ """Initialize a Habitica entity."""
+ self._attr_translation_placeholders = {CONF_NAME: party.name}
+
+ self.entity_description = NotifyEntityDescription(
+ key=HabiticaNotify.PARTY_CHAT,
+ translation_key=HabiticaNotify.PARTY_CHAT,
+ )
+ self.party = party
+ super().__init__(coordinator)
+
+ async def _send_message(self, message: str) -> None:
+ """Send a Habitica party chat message."""
+
+ await self.coordinator.habitica.send_group_message(
+ message=message,
+ group_id=self.party.id,
+ )
+
+
+class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity):
+ """Representation of a Habitica private message notify entity."""
+
+ def __init__(
+ self,
+ coordinator: HabiticaDataUpdateCoordinator,
+ member: UserData,
+ ) -> None:
+ """Initialize a Habitica entity."""
+ self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""}
+ self.entity_description = NotifyEntityDescription(
+ key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}",
+ translation_key=HabiticaNotify.PRIVATE_MESSAGE,
+ )
+ self.member = member
+ super().__init__(coordinator)
+
+ async def _send_message(self, message: str) -> None:
+ """Send a Habitica private message."""
+ if TYPE_CHECKING:
+ assert self.member.id
+ await self.coordinator.habitica.send_private_message(
+ message=message,
+ to_user_id=self.member.id,
+ )
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
index 7a84d589bfb..d13b5562cd6 100644
--- a/homeassistant/components/habitica/sensor.py
+++ b/homeassistant/components/habitica/sensor.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
from enum import StrEnum
import logging
from typing import Any
@@ -33,6 +34,7 @@ from .util import (
pending_quest_items,
quest_attributes,
quest_boss,
+ rage_attributes,
)
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +54,7 @@ PARALLEL_UPDATES = 1
class HabiticaSensorEntityDescription(SensorEntityDescription):
"""Habitica Sensor Description."""
- value_fn: Callable[[UserData, ContentData], StateType]
+ value_fn: Callable[[UserData, ContentData], StateType | datetime]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None
)
@@ -111,6 +113,9 @@ class HabiticaSensorEntity(StrEnum):
BOSS_HP = "boss_hp"
BOSS_HP_REMAINING = "boss_hp_remaining"
COLLECTED_ITEMS = "collected_items"
+ BOSS_RAGE = "boss_rage"
+ BOSS_RAGE_LIMIT = "boss_rage_limit"
+ LAST_CHECKIN = "last_checkin"
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
@@ -281,6 +286,16 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
value_fn=pending_quest_items,
),
+ HabiticaSensorEntityDescription(
+ key=HabiticaSensorEntity.LAST_CHECKIN,
+ translation_key=HabiticaSensorEntity.LAST_CHECKIN,
+ value_fn=(
+ lambda user, _: dt_util.as_local(last)
+ if (last := user.auth.timestamps.loggedin)
+ else None
+ ),
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
)
@@ -342,6 +357,25 @@ SENSOR_DESCRIPTIONS_PARTY: tuple[HabiticaPartySensorEntityDescription, ...] = (
else None
),
),
+ HabiticaPartySensorEntityDescription(
+ key=HabiticaSensorEntity.BOSS_RAGE,
+ translation_key=HabiticaSensorEntity.BOSS_RAGE,
+ value_fn=lambda p, _: p.quest.progress.rage,
+ entity_picture=ha.RAGE,
+ suggested_display_precision=2,
+ ),
+ HabiticaPartySensorEntityDescription(
+ key=HabiticaSensorEntity.BOSS_RAGE_LIMIT,
+ translation_key=HabiticaSensorEntity.BOSS_RAGE_LIMIT,
+ value_fn=(
+ lambda p, c: boss.rage.value
+ if (boss := quest_boss(p, c)) and boss.rage
+ else None
+ ),
+ entity_picture=ha.RAGE,
+ suggested_display_precision=0,
+ attributes_fn=rage_attributes,
+ ),
)
@@ -377,7 +411,7 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
entity_description: HabiticaSensorEntityDescription
@property
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
return self.entity_description.value_fn(
@@ -420,10 +454,12 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
entity_description: HabiticaPartySensorEntityDescription
@property
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
- return self.entity_description.value_fn(self.coordinator.data, self.content)
+ return self.entity_description.value_fn(
+ self.coordinator.data.party, self.content
+ )
@property
def entity_picture(self) -> str | None:
@@ -431,7 +467,9 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
pic = self.entity_description.entity_picture
entity_picture = (
- pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data)
+ pic
+ if isinstance(pic, str) or pic is None
+ else pic(self.coordinator.data.party)
)
return (
@@ -446,5 +484,5 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return entity specific state attributes."""
if func := self.entity_description.attributes_fn:
- return func(self.coordinator.data, self.content)
+ return func(self.coordinator.data.party, self.content)
return None
diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py
index 38833f26932..1c677f18a58 100644
--- a/homeassistant/components/habitica/services.py
+++ b/homeassistant/components/habitica/services.py
@@ -47,6 +47,7 @@ from .const import (
ATTR_ALIAS,
ATTR_CLEAR_DATE,
ATTR_CLEAR_REMINDER,
+ ATTR_COLLAPSE_CHECKLIST,
ATTR_CONFIG_ENTRY,
ATTR_COST,
ATTR_COUNTER_DOWN,
@@ -130,6 +131,11 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
+COLLAPSE_CHECKLIST_MAP = {
+ "collapsed": True,
+ "expanded": False,
+}
+
BASE_TASK_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
@@ -160,6 +166,7 @@ BASE_TASK_SCHEMA = vol.Schema(
vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_COLLAPSE_CHECKLIST): vol.In(COLLAPSE_CHECKLIST_MAP),
vol.Optional(ATTR_START_DATE): cv.date,
vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)),
vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]),
@@ -223,6 +230,7 @@ ITEMID_MAP = {
"shiny_seed": Skill.SHINY_SEED,
}
+
SERVICE_TASK_TYPE_MAP = {
SERVICE_UPDATE_REWARD: TaskType.REWARD,
SERVICE_CREATE_REWARD: TaskType.REWARD,
@@ -714,6 +722,9 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
):
data["checklist"] = checklist
+ if collapse_checklist := call.data.get(ATTR_COLLAPSE_CHECKLIST):
+ data["collapseChecklist"] = COLLAPSE_CHECKLIST_MAP[collapse_checklist]
+
reminders = current_task.reminders if current_task else []
if add_reminders := call.data.get(ATTR_REMINDER):
diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml
index e7f4b4207b0..2752927ac0d 100644
--- a/homeassistant/components/habitica/services.yaml
+++ b/homeassistant/components/habitica/services.yaml
@@ -275,6 +275,15 @@ update_todo:
selector:
text:
multiple: true
+ collapse_checklist: &collapse_checklist
+ required: false
+ selector:
+ select:
+ options:
+ - collapsed
+ - expanded
+ mode: list
+ translation_key: collapse_checklist
priority: *priority
duedate_options:
collapsed: true
@@ -318,6 +327,7 @@ create_todo:
name: *name
notes: *notes
add_checklist_item: *add_checklist_item
+ collapse_checklist: *collapse_checklist
priority: *priority
date: *due_date
reminder: *reminder
@@ -419,6 +429,7 @@ create_daily:
name: *name
notes: *notes
add_checklist_item: *add_checklist_item
+ collapse_checklist: *collapse_checklist
priority: *priority
start_date: *start_date
frequency: *frequency_daily
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index 1d62b242149..53e570bd978 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -8,6 +8,7 @@
"unit_mana_points": "MP",
"unit_experience_points": "XP",
"unit_items": "items",
+ "unit_rage": "rage",
"config_entry_description": "Select the Habitica account to update a task.",
"task_description": "The name (or task ID) of the task you want to update.",
"rename_name": "Rename",
@@ -65,7 +66,9 @@
"repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.",
"repeat_monthly_options_name": "Monthly repeat day",
"repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.",
- "quest_name": "Quest"
+ "quest_name": "Quest",
+ "collapse_checklist_name": "Collapse/expand checklist",
+ "collapse_checklist_description": "Whether the checklist of a task is displayed as collapsed or expanded in Habitica."
},
"config": {
"abort": {
@@ -261,6 +264,14 @@
"name": "[%key:component::habitica::common::quest_name%]"
}
},
+ "notify": {
+ "party_chat": {
+ "name": "Party chat"
+ },
+ "private_message": {
+ "name": "Private message: {name}"
+ }
+ },
"sensor": {
"display_name": {
"name": "Display name",
@@ -279,6 +290,9 @@
}
}
},
+ "last_checkin": {
+ "name": "Last check-in"
+ },
"health": {
"name": "Health",
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
@@ -459,6 +473,22 @@
"collected_items": {
"name": "Collected quest items",
"unit_of_measurement": "[%key:component::habitica::common::unit_items%]"
+ },
+ "boss_rage_limit": {
+ "name": "Boss rage limit break",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_rage%]",
+ "state_attributes": {
+ "rage_skill": {
+ "name": "Rage skill"
+ },
+ "effect": {
+ "name": "Effect"
+ }
+ }
+ },
+ "boss_rage": {
+ "name": "Boss rage",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_rage%]"
}
},
"switch": {
@@ -553,6 +583,12 @@
},
"frequency_not_monthly": {
"message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
+ },
+ "send_message_forbidden": {
+ "message": "You are not allowed to send messages to {name}. ({reason})"
+ },
+ "send_message_not_found": {
+ "message": "Unable to send message, {name} not found. ({reason})"
}
},
"issues": {
@@ -989,6 +1025,10 @@
"unscore_checklist_item": {
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
+ },
+ "collapse_checklist": {
+ "name": "[%key:component::habitica::common::collapse_checklist_name%]",
+ "description": "[%key:component::habitica::common::collapse_checklist_description%]"
}
},
"sections": {
@@ -1053,6 +1093,10 @@
"add_checklist_item": {
"name": "[%key:component::habitica::common::checklist_options_name%]",
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
+ },
+ "collapse_checklist": {
+ "name": "[%key:component::habitica::common::collapse_checklist_name%]",
+ "description": "[%key:component::habitica::common::collapse_checklist_description%]"
}
},
"sections": {
@@ -1134,6 +1178,10 @@
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
},
+ "collapse_checklist": {
+ "name": "[%key:component::habitica::common::collapse_checklist_name%]",
+ "description": "[%key:component::habitica::common::collapse_checklist_description%]"
+ },
"streak": {
"name": "Adjust streak",
"description": "Adjust or reset the streak counter of the daily."
@@ -1230,6 +1278,10 @@
"name": "[%key:component::habitica::common::checklist_options_name%]",
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
},
+ "collapse_checklist": {
+ "name": "[%key:component::habitica::common::collapse_checklist_name%]",
+ "description": "[%key:component::habitica::common::collapse_checklist_description%]"
+ },
"reminder": {
"name": "[%key:component::habitica::common::reminder_options_name%]",
"description": "[%key:component::habitica::common::reminder_description%]"
@@ -1308,6 +1360,12 @@
"day_of_month": "Day of the month",
"day_of_week": "Day of the week"
}
+ },
+ "collapse_checklist": {
+ "options": {
+ "collapsed": "Collapsed",
+ "expanded": "Expanded"
+ }
}
}
}
diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py
index 8c2148192a3..7ba4ddb11f8 100644
--- a/homeassistant/components/habitica/util.py
+++ b/homeassistant/components/habitica/util.py
@@ -196,6 +196,15 @@ def quest_attributes(party: GroupData, content: ContentData) -> dict[str, Any]:
}
+def rage_attributes(party: GroupData, content: ContentData) -> dict[str, Any]:
+ """Display name of rage skill and description of it's effect in attributes."""
+ boss = quest_boss(party, content)
+ return {
+ "rage_skill": boss.rage.title if boss and boss.rage else None,
+ "effect": boss.rage.effect if boss and boss.rage else None,
+ }
+
+
def quest_boss(party: GroupData, content: ContentData) -> QuestBoss | None:
"""Quest boss."""
diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json
index f67eb4db5aa..f74bff314a4 100644
--- a/homeassistant/components/harmony/manifest.json
+++ b/homeassistant/components/harmony/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
- "requirements": ["aioharmony==0.5.2"],
+ "requirements": ["aioharmony==0.5.3"],
"ssdp": [
{
"manufacturer": "Logitech",
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index 0c15a687421..e352f8d0cb3 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -73,6 +73,7 @@ from . import ( # noqa: F401
config_flow,
diagnostics,
sensor,
+ switch,
system_health,
update,
)
@@ -149,7 +150,7 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
# If new platforms are added, be sure to import them above
# so we do not make other components that depend on hassio
# wait for the import of the platforms
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
CONF_FRONTEND_REPO = "development_repo"
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
index a639833c381..1653c33e5ec 100644
--- a/homeassistant/components/hassio/const.py
+++ b/homeassistant/components/hassio/const.py
@@ -112,11 +112,14 @@ PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
+PLACEHOLDER_KEY_FREE_SPACE = "free_space"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
+ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
+ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
@@ -137,6 +140,24 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
REQUEST_REFRESH_DELAY = 10
+HELP_URLS = {
+ "help_url": "https://www.home-assistant.io/help/",
+ "community_url": "https://community.home-assistant.io/",
+}
+
+EXTRA_PLACEHOLDERS = {
+ "issue_mount_mount_failed": {
+ "storage_url": "/config/storage",
+ },
+ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
+ ISSUE_KEY_SYSTEM_FREE_SPACE: {
+ "more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
+ },
+ ISSUE_KEY_ADDON_PWNED: {
+ "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
+ },
+}
+
class SupervisorEntityModel(StrEnum):
"""Supervisor entity model."""
diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py
index 5532c66d1ae..2a41bbc2bda 100644
--- a/homeassistant/components/hassio/coordinator.py
+++ b/homeassistant/components/hassio/coordinator.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
+from copy import deepcopy
import logging
from typing import TYPE_CHECKING, Any
@@ -545,3 +546,15 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
await super()._async_refresh(
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
)
+
+ async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
+ """Force refresh of addon info data for a specific addon."""
+ try:
+ slug, info = await self._update_addon_info(addon_slug)
+ if info is not None and DATA_KEY_ADDONS in self.data:
+ if slug in self.data[DATA_KEY_ADDONS]:
+ data = deepcopy(self.data)
+ data[DATA_KEY_ADDONS][slug].update(info)
+ self.async_set_updated_data(data)
+ except SupervisorError as err:
+ _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py
index 2b34a48149b..60417a3dd65 100644
--- a/homeassistant/components/hassio/http.py
+++ b/homeassistant/components/hassio/http.py
@@ -70,7 +70,7 @@ PATHS_ADMIN = re.compile(
r"|backups/new/upload"
r"|audio/logs(/follow|/boots/-?\d+(/follow)?)?"
r"|cli/logs(/follow|/boots/-?\d+(/follow)?)?"
- r"|core/logs(/follow|/boots/-?\d+(/follow)?)?"
+ r"|core/logs(/latest|/follow|/boots/-?\d+(/follow)?)?"
r"|dns/logs(/follow|/boots/-?\d+(/follow)?)?"
r"|host/logs(/follow|/boots(/-?\d+(/follow)?)?)?"
r"|multicast/logs(/follow|/boots/-?\d+(/follow)?)?"
diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py
index e1f96b76bcb..284138956ff 100644
--- a/homeassistant/components/hassio/ingress.py
+++ b/homeassistant/components/hassio/ingress.py
@@ -139,21 +139,27 @@ class HassIOIngress(HomeAssistantView):
url = url.with_query(request.query_string)
# Start proxy
- async with self._websession.ws_connect(
- url,
- headers=source_header,
- protocols=req_protocols,
- autoclose=False,
- autoping=False,
- ) as ws_client:
- # Proxy requests
- await asyncio.wait(
- [
- create_eager_task(_websocket_forward(ws_server, ws_client)),
- create_eager_task(_websocket_forward(ws_client, ws_server)),
- ],
- return_when=asyncio.FIRST_COMPLETED,
+ try:
+ _LOGGER.debug(
+ "Proxying WebSocket to %s / %s, upstream url: %s", token, path, url
)
+ async with self._websession.ws_connect(
+ url,
+ headers=source_header,
+ protocols=req_protocols,
+ autoclose=False,
+ autoping=False,
+ ) as ws_client:
+ # Proxy requests
+ await asyncio.wait(
+ [
+ create_eager_task(_websocket_forward(ws_server, ws_client)),
+ create_eager_task(_websocket_forward(ws_client, ws_server)),
+ ],
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ except TimeoutError:
+ _LOGGER.warning("WebSocket proxy to %s / %s timed out", token, path)
return ws_server
@@ -226,6 +232,7 @@ class HassIOIngress(HomeAssistantView):
aiohttp.ClientError,
aiohttp.ClientPayloadError,
ConnectionResetError,
+ ConnectionError,
) as err:
_LOGGER.debug("Stream error %s / %s: %s", token, path, err)
@@ -303,9 +310,9 @@ async def _websocket_forward(
elif msg.type is aiohttp.WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type is aiohttp.WSMsgType.PING:
- await ws_to.ping()
+ await ws_to.ping(msg.data)
elif msg.type is aiohttp.WSMsgType.PONG:
- await ws_to.pong()
+ await ws_to.pong(msg.data)
elif ws_to.closed:
await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type]
except RuntimeError:
diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py
index 22406e86ba1..df1ca87fe0b 100644
--- a/homeassistant/components/hassio/issues.py
+++ b/homeassistant/components/hassio/issues.py
@@ -10,7 +10,12 @@ from typing import Any, NotRequired, TypedDict
from uuid import UUID
from aiohasupervisor import SupervisorError
-from aiohasupervisor.models import ContextType, Issue as SupervisorIssue
+from aiohasupervisor.models import (
+ ContextType,
+ Issue as SupervisorIssue,
+ UnhealthyReason,
+ UnsupportedReason,
+)
from homeassistant.core import HassJob, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -36,17 +41,21 @@ from .const import (
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
+ EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
+ ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
+ ISSUE_KEY_SYSTEM_FREE_SPACE,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_URL,
+ PLACEHOLDER_KEY_FREE_SPACE,
PLACEHOLDER_KEY_REFERENCE,
REQUEST_REFRESH_DELAY,
UPDATE_KEY_SUPERVISOR,
)
-from .coordinator import get_addons_info
+from .coordinator import get_addons_info, get_host_info
from .handler import HassIO, get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
@@ -59,42 +68,9 @@ INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
PLACEHOLDER_KEY_REASON = "reason"
-UNSUPPORTED_REASONS = {
- "apparmor",
- "cgroup_version",
- "connectivity_check",
- "content_trust",
- "dbus",
- "dns_server",
- "docker_configuration",
- "docker_version",
- "job_conditions",
- "lxc",
- "network_manager",
- "os",
- "os_agent",
- "os_version",
- "restart_policy",
- "software",
- "source_mods",
- "supervisor_version",
- "systemd",
- "systemd_journal",
- "systemd_resolved",
- "virtualization_image",
-}
# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
# provides no additional information beyond the unhealthy one then skip that repair.
UNSUPPORTED_SKIP_REPAIR = {"privileged"}
-UNHEALTHY_REASONS = {
- "docker",
- "duplicate_os_installation",
- "oserror_bad_message",
- "privileged",
- "setup",
- "supervisor",
- "untrusted",
-}
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
@@ -106,6 +82,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
"issue_system_disk_lifetime",
+ ISSUE_KEY_SYSTEM_FREE_SPACE,
+ ISSUE_KEY_ADDON_PWNED,
}
_LOGGER = logging.getLogger(__name__)
@@ -206,7 +184,7 @@ class SupervisorIssues:
def unhealthy_reasons(self, reasons: set[str]) -> None:
"""Set unhealthy reasons. Create or delete repairs as necessary."""
for unhealthy in reasons - self.unhealthy_reasons:
- if unhealthy in UNHEALTHY_REASONS:
+ if unhealthy in UnhealthyReason:
translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
translation_placeholders = None
else:
@@ -238,7 +216,7 @@ class SupervisorIssues:
def unsupported_reasons(self, reasons: set[str]) -> None:
"""Set unsupported reasons. Create or delete repairs as necessary."""
for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
- if unsupported in UNSUPPORTED_REASONS:
+ if unsupported in UnsupportedReason:
translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
translation_placeholders = None
else:
@@ -269,11 +247,17 @@ class SupervisorIssues:
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
- placeholders: dict[str, str] | None = None
- if issue.reference:
- placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
+ placeholders: dict[str, str] = {}
+ if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
+ placeholders |= EXTRA_PLACEHOLDERS[issue.key]
- if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
+ if issue.reference:
+ placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
+
+ if issue.key in {
+ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
+ ISSUE_KEY_ADDON_PWNED,
+ }:
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
f"/hassio/addon/{issue.reference}"
)
@@ -285,6 +269,19 @@ class SupervisorIssues:
else:
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
+ elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
+ host_info = get_host_info(self._hass)
+ if (
+ host_info
+ and "data" in host_info
+ and "disk_free" in host_info["data"]
+ ):
+ placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
+ host_info["data"]["disk_free"]
+ )
+ else:
+ placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
+
async_create_issue(
self._hass,
DOMAIN,
@@ -292,7 +289,7 @@ class SupervisorIssues:
is_fixable=bool(issue.suggestions),
severity=IssueSeverity.WARNING,
translation_key=issue.key,
- translation_placeholders=placeholders,
+ translation_placeholders=placeholders or None,
)
self._issues[issue.uuid] = issue
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
index 34a8f466158..c6a419bba83 100644
--- a/homeassistant/components/hassio/manifest.json
+++ b/homeassistant/components/hassio/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
- "requirements": ["aiohasupervisor==0.3.2b0"],
+ "requirements": ["aiohasupervisor==0.3.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py
index 0e8122c08b9..ff32e2cbab9 100644
--- a/homeassistant/components/hassio/repairs.py
+++ b/homeassistant/components/hassio/repairs.py
@@ -16,8 +16,10 @@ from homeassistant.data_entry_flow import FlowResult
from . import get_addons_info, get_issues_info
from .const import (
+ EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
+ ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_COMPONENTS,
@@ -26,11 +28,6 @@ from .const import (
from .handler import get_supervisor_client
from .issues import Issue, Suggestion
-HELP_URLS = {
- "help_url": "https://www.home-assistant.io/help/",
- "community_url": "https://community.home-assistant.io/",
-}
-
SUGGESTION_CONFIRMATION_REQUIRED = {
"addon_execute_remove",
"system_adopt_data_disk",
@@ -38,14 +35,6 @@ SUGGESTION_CONFIRMATION_REQUIRED = {
}
-EXTRA_PLACEHOLDERS = {
- "issue_mount_mount_failed": {
- "storage_url": "/config/storage",
- },
- ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
-}
-
-
class SupervisorIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -219,6 +208,7 @@ async def async_create_fix_flow(
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
+ ISSUE_KEY_ADDON_PWNED,
}:
return AddonIssueRepairFlow(hass, issue_id)
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 393fe480057..d93fff8d06d 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -37,14 +37,14 @@
},
"issue_addon_detached_addon_missing": {
"title": "Missing repository for an installed add-on",
- "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."
+ "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."
},
"issue_addon_detached_addon_removed": {
"title": "Installed add-on has been removed from repository",
"fix_flow": {
"step": {
"addon_execute_remove": {
- "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
+ "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
},
"abort": {
@@ -52,6 +52,10 @@
}
}
},
+ "issue_addon_pwned": {
+ "title": "Insecure secrets detected in add-on configuration",
+ "description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
+ },
"issue_mount_mount_failed": {
"title": "Network storage device failed",
"fix_flow": {
@@ -119,6 +123,10 @@
"title": "Disk lifetime exceeding 90%",
"description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data."
},
+ "issue_system_free_space": {
+ "title": "Data disk is running low on free space",
+ "description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space."
+ },
"unhealthy": {
"title": "Unhealthy system - {reason}",
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more."
@@ -185,7 +193,7 @@
},
"unsupported_docker_version": {
"title": "Unsupported system - Docker version",
- "description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this."
+ "description": "System is unsupported because the Docker version is out of date. For information about the required version and how to fix this, select Learn more."
},
"unsupported_job_conditions": {
"title": "Unsupported system - Protections disabled",
@@ -201,7 +209,7 @@
},
"unsupported_os": {
"title": "Unsupported system - Operating System",
- "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this."
+ "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. For information about supported operating systems and how to fix this, select Learn more."
},
"unsupported_os_agent": {
"title": "Unsupported system - OS-Agent issues",
@@ -242,6 +250,10 @@
"unsupported_os_version": {
"title": "Unsupported system - Home Assistant OS version",
"description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more."
+ },
+ "unsupported_home_assistant_core_version": {
+ "title": "Unsupported system - Home Assistant Core version",
+ "description": "System is unsupported because the Home Assistant Core version in use is not supported. For troubleshooting information, select Learn more."
}
},
"entity": {
diff --git a/homeassistant/components/hassio/switch.py b/homeassistant/components/hassio/switch.py
new file mode 100644
index 00000000000..4aa7813783a
--- /dev/null
+++ b/homeassistant/components/hassio/switch.py
@@ -0,0 +1,89 @@
+"""Switch platform for Hass.io addons."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from aiohasupervisor import SupervisorError
+
+from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ICON
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
+from .entity import HassioAddonEntity
+from .handler import get_supervisor_client
+
+_LOGGER = logging.getLogger(__name__)
+
+
+ENTITY_DESCRIPTION = SwitchEntityDescription(
+ key=ATTR_STATE,
+ name=None,
+ icon="mdi:puzzle",
+ entity_registry_enabled_default=False,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Switch set up for Hass.io config entry."""
+ coordinator = hass.data[ADDONS_COORDINATOR]
+
+ async_add_entities(
+ HassioAddonSwitch(
+ addon=addon,
+ coordinator=coordinator,
+ entity_description=ENTITY_DESCRIPTION,
+ )
+ for addon in coordinator.data[DATA_KEY_ADDONS].values()
+ )
+
+
+class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
+ """Switch for Hass.io add-ons."""
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the add-on is on."""
+ addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
+ state = addon_data.get(self.entity_description.key)
+ return state == ATTR_STARTED
+
+ @property
+ def entity_picture(self) -> str | None:
+ """Return the icon of the add-on if any."""
+ if not self.available:
+ return None
+ addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
+ if addon_data.get(ATTR_ICON):
+ return f"/api/hassio/addons/{self._addon_slug}/icon"
+ return None
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ supervisor_client = get_supervisor_client(self.hass)
+ try:
+ await supervisor_client.addons.start_addon(self._addon_slug)
+ except SupervisorError as err:
+ raise HomeAssistantError(err) from err
+
+ await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ supervisor_client = get_supervisor_client(self.hass)
+ try:
+ await supervisor_client.addons.stop_addon(self._addon_slug)
+ except SupervisorError as err:
+ _LOGGER.error("Failed to stop addon %s: %s", self._addon_slug, err)
+ raise HomeAssistantError(err) from err
+
+ await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index dd0cef0ec10..b93f2e2b234 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -295,7 +295,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
) -> None:
"""Play a piece of media."""
if heos_source.is_media_uri(media_id):
- media, data = heos_source.from_media_uri(media_id)
+ media, _data = heos_source.from_media_uri(media_id)
if not isinstance(media, MediaItem):
raise ValueError(f"Invalid media id '{media_id}'")
await self._player.play_media(
@@ -610,7 +610,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia:
"""Browse a HEOS media item."""
- media, data = heos_source.from_media_uri(media_content_id)
+ media, _data = heos_source.from_media_uri(media_content_id)
browse_media = _media_to_browse_media(media)
try:
browse_result = await self.coordinator.heos.browse_media(media)
diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py
index 741a9a1058c..9de8230e357 100644
--- a/homeassistant/components/here_travel_time/__init__.py
+++ b/homeassistant/components/here_travel_time/__init__.py
@@ -6,9 +6,14 @@ import logging
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
from homeassistant.helpers.start import async_at_started
-from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC
+from .const import CONF_TRAFFIC_MODE, DOMAIN, TRAVEL_MODE_PUBLIC
from .coordinator import (
HereConfigEntry,
HERERoutingDataUpdateCoordinator,
@@ -24,6 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
"""Set up HERE Travel Time from a config entry."""
api_key = config_entry.data[CONF_API_KEY]
+ alert_for_multiple_entries(hass)
+
cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator]
if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}:
cls = HERETransitDataUpdateCoordinator
@@ -42,6 +49,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
return True
+def alert_for_multiple_entries(hass: HomeAssistant) -> None:
+ """Check if there are multiple entries for the same API key."""
+ if len(hass.config_entries.async_entries(DOMAIN)) > 1:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "multiple_here_travel_time_entries",
+ learn_more_url="https://www.home-assistant.io/integrations/here_travel_time/",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="multiple_here_travel_time_entries",
+ translation_placeholders={
+ "pricing_page": "https://www.here.com/get-started/pricing",
+ },
+ )
+ else:
+ async_delete_issue(
+ hass,
+ DOMAIN,
+ "multiple_here_travel_time_entries",
+ )
+
+
async def async_unload_entry(
hass: HomeAssistant, config_entry: HereConfigEntry
) -> bool:
diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py
index da93c6e301e..1500006fc39 100644
--- a/homeassistant/components/here_travel_time/sensor.py
+++ b/homeassistant/components/here_travel_time/sensor.py
@@ -44,7 +44,7 @@ from .coordinator import (
HERETransitDataUpdateCoordinator,
)
-SCAN_INTERVAL = timedelta(minutes=5)
+SCAN_INTERVAL = timedelta(minutes=30)
def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]:
diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json
index 639be3326f9..ec457bf7099 100644
--- a/homeassistant/components/here_travel_time/strings.json
+++ b/homeassistant/components/here_travel_time/strings.json
@@ -107,5 +107,11 @@
"name": "Destination"
}
}
+ },
+ "issues": {
+ "multiple_here_travel_time_entries": {
+ "title": "More than one HERE Travel Time integration detected",
+ "description": "HERE deprecated the previous free tier. The new Base Plan has only 5000 instead of the previous 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost."
+ }
}
}
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
index fd82b74b048..b948060fe24 100644
--- a/homeassistant/components/history/__init__.py
+++ b/homeassistant/components/history/__init__.py
@@ -46,7 +46,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the history hooks."""
hass.http.register_view(HistoryPeriodView())
- frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box")
+ frontend.async_register_built_in_panel(hass, "history", "history", "mdi:chart-box")
websocket_api.async_setup(hass)
return True
diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py
index efddabd180c..87efcf274bd 100644
--- a/homeassistant/components/history_stats/__init__.py
+++ b/homeassistant/components/history_stats/__init__.py
@@ -65,6 +65,7 @@ async def async_setup_entry(
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -86,7 +87,6 @@ async def async_setup_entry(
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -130,8 +130,3 @@ async def async_unload_entry(
) -> bool:
"""Unload History stats config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py
index 750180bf3f6..84232ef8873 100644
--- a/homeassistant/components/history_stats/config_flow.py
+++ b/homeassistant/components/history_stats/config_flow.py
@@ -26,9 +26,10 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
+ StateSelector,
+ StateSelectorConfig,
TemplateSelector,
TextSelector,
- TextSelectorConfig,
)
from homeassistant.helpers.template import Template
@@ -67,7 +68,6 @@ DATA_SCHEMA_SETUP = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_ENTITY_ID): EntitySelector(),
- vol.Required(CONF_STATE): TextSelector(TextSelectorConfig(multiple=True)),
vol.Required(CONF_TYPE, default=CONF_TYPE_TIME): SelectSelector(
SelectSelectorConfig(
options=CONF_TYPE_KEYS,
@@ -77,44 +77,78 @@ DATA_SCHEMA_SETUP = vol.Schema(
),
}
)
-DATA_SCHEMA_OPTIONS = vol.Schema(
- {
- vol.Optional(CONF_ENTITY_ID): EntitySelector(
- EntitySelectorConfig(read_only=True)
- ),
- vol.Optional(CONF_STATE): TextSelector(
- TextSelectorConfig(multiple=True, read_only=True)
- ),
- vol.Optional(CONF_TYPE): SelectSelector(
- SelectSelectorConfig(
- options=CONF_TYPE_KEYS,
- mode=SelectSelectorMode.DROPDOWN,
- translation_key=CONF_TYPE,
- read_only=True,
- )
- ),
- vol.Optional(CONF_START): TemplateSelector(),
- vol.Optional(CONF_END): TemplateSelector(),
- vol.Optional(CONF_DURATION): DurationSelector(
- DurationSelectorConfig(enable_day=True, allow_negative=False)
- ),
- }
-)
+
+
+async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
+ """Return schema for state step."""
+ entity_id = handler.options[CONF_ENTITY_ID]
+
+ return vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(read_only=True)
+ ),
+ vol.Required(CONF_STATE): StateSelector(
+ StateSelectorConfig(
+ multiple=True,
+ entity_id=entity_id,
+ )
+ ),
+ }
+ )
+
+
+async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
+ """Return schema for options step."""
+ entity_id = handler.options[CONF_ENTITY_ID]
+ return _get_options_schema_with_entity_id(entity_id)
+
+
+def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
+ return vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY_ID): EntitySelector(
+ EntitySelectorConfig(read_only=True)
+ ),
+ vol.Optional(CONF_STATE): StateSelector(
+ StateSelectorConfig(
+ multiple=True,
+ entity_id=entity_id,
+ read_only=True,
+ )
+ ),
+ vol.Optional(CONF_TYPE): SelectSelector(
+ SelectSelectorConfig(
+ options=CONF_TYPE_KEYS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_TYPE,
+ read_only=True,
+ )
+ ),
+ vol.Optional(CONF_START): TemplateSelector(),
+ vol.Optional(CONF_END): TemplateSelector(),
+ vol.Optional(CONF_DURATION): DurationSelector(
+ DurationSelectorConfig(enable_day=True, allow_negative=False)
+ ),
+ }
+ )
+
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP,
- next_step="options",
+ next_step="state",
),
+ "state": SchemaFlowFormStep(schema=get_state_schema, next_step="options"),
"options": SchemaFlowFormStep(
- schema=DATA_SCHEMA_OPTIONS,
+ schema=get_options_schema,
validate_user_input=validate_options,
preview="history_stats",
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
- DATA_SCHEMA_OPTIONS,
+ schema=get_options_schema,
validate_user_input=validate_options,
preview="history_stats",
),
@@ -128,6 +162,7 @@ class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
@@ -198,7 +233,9 @@ async def ws_start_preview(
validated_data: Any = None
try:
- validated_data = DATA_SCHEMA_OPTIONS(msg["user_input"])
+ validated_data = (_get_options_schema_with_entity_id(entity_id))(
+ msg["user_input"]
+ )
except vol.Invalid as ex:
connection.send_error(msg["id"], "invalid_schema", str(ex))
return
diff --git a/homeassistant/components/history_stats/diagnostics.py b/homeassistant/components/history_stats/diagnostics.py
new file mode 100644
index 00000000000..045e37d49b9
--- /dev/null
+++ b/homeassistant/components/history_stats/diagnostics.py
@@ -0,0 +1,23 @@
+"""Diagnostics support for history_stats."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ registry = er.async_get(hass)
+ entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
+
+ return {
+ "config_entry": config_entry.as_dict(),
+ "entity": [entity.extended_dict for entity in entities],
+ }
diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json
index 7a33099cf99..7c4a1cfa677 100644
--- a/homeassistant/components/history_stats/strings.json
+++ b/homeassistant/components/history_stats/strings.json
@@ -23,6 +23,16 @@
"type": "The type of sensor, one of 'time', 'ratio' or 'count'"
}
},
+ "state": {
+ "data": {
+ "entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
+ "state": "[%key:component::history_stats::config::step::user::data::state%]"
+ },
+ "data_description": {
+ "entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
+ "state": "[%key:component::history_stats::config::step::user::data_description::state%]"
+ }
+ },
"options": {
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
"data": {
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 5ea0d217f14..82e83275b6b 100644
--- a/homeassistant/components/holiday/manifest.json
+++ b/homeassistant/components/holiday/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
- "requirements": ["holidays==0.79", "babel==2.15.0"]
+ "requirements": ["holidays==0.81", "babel==2.15.0"]
}
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index 4a48d1f1ad7..f0d5e7dbf02 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -37,7 +37,6 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
- Platform.TIME,
]
diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py
index 20a3a211b6a..d66255e6810 100644
--- a/homeassistant/components/home_connect/application_credentials.py
+++ b/homeassistant/components/home_connect/application_credentials.py
@@ -12,13 +12,3 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
-
-
-async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
- """Return description placeholders for the credentials dialog."""
- return {
- "developer_dashboard_url": "https://developer.home-connect.com/",
- "applications_url": "https://developer.home-connect.com/applications",
- "register_application_url": "https://developer.home-connect.com/application/add",
- "redirect_url": "https://my.home-assistant.io/redirect/oauth",
- }
diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py
index 81f785b55ae..92ede6a5a3a 100644
--- a/homeassistant/components/home_connect/coordinator.py
+++ b/homeassistant/components/home_connect/coordinator.py
@@ -659,17 +659,3 @@ class HomeConnectCoordinator(
)
return False
-
- async def reset_execution_tracker(self, appliance_ha_id: str) -> None:
- """Reset the execution tracker for a specific appliance."""
- self._execution_tracker.pop(appliance_ha_id, None)
- appliance_info = await self.client.get_specific_appliance(appliance_ha_id)
-
- appliance_data = await self._get_appliance_data(
- appliance_info, self.data.get(appliance_info.ha_id)
- )
- self.data[appliance_ha_id].update(appliance_data)
- for listener, context in self._special_listeners.values():
- if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
- listener()
- self._call_all_event_listeners_for_appliance(appliance_ha_id)
diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json
index 9b4c9276998..0e8f1c4f988 100644
--- a/homeassistant/components/home_connect/icons.json
+++ b/homeassistant/components/home_connect/icons.json
@@ -66,6 +66,14 @@
"default": "mdi:stop"
}
},
+ "number": {
+ "start_in_relative": {
+ "default": "mdi:progress-clock"
+ },
+ "finish_in_relative": {
+ "default": "mdi:progress-clock"
+ }
+ },
"sensor": {
"operation_state": {
"default": "mdi:state-machine",
@@ -251,14 +259,6 @@
"i_dos_2_active": {
"default": "mdi:numeric-2-circle"
}
- },
- "time": {
- "start_in_relative": {
- "default": "mdi:progress-clock"
- },
- "finish_in_relative": {
- "default": "mdi:progress-clock"
- }
}
}
}
diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json
index 2008e618f5e..b9fc230e749 100644
--- a/homeassistant/components/home_connect/manifest.json
+++ b/homeassistant/components/home_connect/manifest.json
@@ -22,6 +22,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
- "requirements": ["aiohomeconnect==0.18.1"],
+ "requirements": ["aiohomeconnect==0.20.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}
diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py
deleted file mode 100644
index 21c6775e549..00000000000
--- a/homeassistant/components/home_connect/repairs.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Repairs flows for Home Connect."""
-
-from typing import cast
-
-import voluptuous as vol
-
-from homeassistant import data_entry_flow
-from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-
-from .coordinator import HomeConnectConfigEntry
-
-
-class EnableApplianceUpdatesFlow(RepairsFlow):
- """Handler for enabling appliance's updates after being refreshed too many times."""
-
- async def async_step_init(
- self, user_input: dict[str, str] | None = None
- ) -> data_entry_flow.FlowResult:
- """Handle the first step of a fix flow."""
- return await self.async_step_confirm()
-
- async def async_step_confirm(
- self, user_input: dict[str, str] | None = None
- ) -> data_entry_flow.FlowResult:
- """Handle the confirm step of a fix flow."""
- if user_input is not None:
- assert self.data
- entry = self.hass.config_entries.async_get_entry(
- cast(str, self.data["entry_id"])
- )
- assert entry
- entry = cast(HomeConnectConfigEntry, entry)
- await entry.runtime_data.reset_execution_tracker(
- cast(str, self.data["appliance_ha_id"])
- )
- return self.async_create_entry(data={})
-
- issue_registry = ir.async_get(self.hass)
- description_placeholders = None
- if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
- description_placeholders = issue.translation_placeholders
-
- return self.async_show_form(
- step_id="confirm",
- data_schema=vol.Schema({}),
- description_placeholders=description_placeholders,
- )
-
-
-async def async_create_fix_flow(
- hass: HomeAssistant,
- issue_id: str,
- data: dict[str, str | int | float | None] | None,
-) -> RepairsFlow:
- """Create flow."""
- if issue_id.startswith("home_connect_too_many_connected_paired_events"):
- return EnableApplianceUpdatesFlow()
- return ConfirmRepairFlow()
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index 1d3bffb7847..2ef931ec52a 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -1852,11 +1852,6 @@
"i_dos2_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
}
- },
- "time": {
- "alarm_clock": {
- "name": "Alarm clock"
- }
}
}
}
diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py
deleted file mode 100644
index 6a6e57c4dd3..00000000000
--- a/homeassistant/components/home_connect/time.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""Provides time entities for Home Connect."""
-
-from datetime import time
-from typing import cast
-
-from aiohomeconnect.model import SettingKey
-from aiohomeconnect.model.error import HomeConnectError
-
-from homeassistant.components.automation import automations_with_entity
-from homeassistant.components.script import scripts_with_entity
-from homeassistant.components.time import TimeEntity, TimeEntityDescription
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-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 .common import setup_home_connect_entry
-from .const import DOMAIN
-from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
-from .entity import HomeConnectEntity
-from .utils import get_dict_from_home_connect_error
-
-PARALLEL_UPDATES = 1
-
-TIME_ENTITIES = (
- TimeEntityDescription(
- key=SettingKey.BSH_COMMON_ALARM_CLOCK,
- translation_key="alarm_clock",
- entity_registry_enabled_default=False,
- ),
-)
-
-
-def _get_entities_for_appliance(
- entry: HomeConnectConfigEntry,
- appliance: HomeConnectApplianceData,
-) -> list[HomeConnectEntity]:
- """Get a list of entities."""
- return [
- HomeConnectTimeEntity(entry.runtime_data, appliance, description)
- for description in TIME_ENTITIES
- if description.key in appliance.settings
- ]
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: HomeConnectConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Set up the Home Connect switch."""
- setup_home_connect_entry(
- entry,
- _get_entities_for_appliance,
- async_add_entities,
- )
-
-
-def seconds_to_time(seconds: int) -> time:
- """Convert seconds to a time object."""
- minutes, sec = divmod(seconds, 60)
- hours, minutes = divmod(minutes, 60)
- return time(hour=hours, minute=minutes, second=sec)
-
-
-def time_to_seconds(t: time) -> int:
- """Convert a time object to seconds."""
- return t.hour * 3600 + t.minute * 60 + t.second
-
-
-class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
- """Time setting class for Home Connect."""
-
- async def async_added_to_hass(self) -> None:
- """Call when entity is added to hass."""
- await super().async_added_to_hass()
- if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK:
- automations = automations_with_entity(self.hass, self.entity_id)
- scripts = scripts_with_entity(self.hass, self.entity_id)
- items = automations + scripts
- if not items:
- return
-
- entity_reg: er.EntityRegistry = er.async_get(self.hass)
- entity_automations = [
- automation_entity
- for automation_id in automations
- if (automation_entity := entity_reg.async_get(automation_id))
- ]
- entity_scripts = [
- script_entity
- for script_id in scripts
- if (script_entity := entity_reg.async_get(script_id))
- ]
-
- items_list = [
- f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
- for item in entity_automations
- ] + [
- f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
- for item in entity_scripts
- ]
-
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
- breaks_in_ha_version="2025.10.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_time_alarm_clock",
- translation_placeholders={
- "entity_id": self.entity_id,
- "items": "\n".join(items_list),
- },
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Call when entity will be removed from hass."""
- if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK:
- async_delete_issue(
- self.hass,
- DOMAIN,
- f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
- )
- async_delete_issue(
- self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}"
- )
-
- async def async_set_value(self, value: time) -> None:
- """Set the native value of the entity."""
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_time_alarm_clock_{self.entity_id}",
- breaks_in_ha_version="2025.10.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_time_alarm_clock",
- translation_placeholders={
- "entity_id": self.entity_id,
- },
- )
- try:
- await self.coordinator.client.set_setting(
- self.appliance.info.ha_id,
- setting_key=SettingKey(self.bsh_key),
- value=time_to_seconds(value),
- )
- except HomeConnectError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="set_setting_entity",
- translation_placeholders={
- **get_dict_from_home_connect_error(err),
- "entity_id": self.entity_id,
- "key": self.bsh_key,
- "value": str(value),
- },
- ) from err
-
- def update_native_value(self) -> None:
- """Set the value of the entity."""
- data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
- self._attr_native_value = seconds_to_time(data.value)
diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py
index 32fe690f0f1..d0892df399d 100644
--- a/homeassistant/components/homeassistant/__init__.py
+++ b/homeassistant/components/homeassistant/__init__.py
@@ -339,7 +339,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
reload_entries: set[str] = set()
if ATTR_ENTRY_ID in call.data:
reload_entries.add(call.data[ATTR_ENTRY_ID])
- reload_entries.update(await async_extract_config_entry_ids(hass, call))
+ reload_entries.update(await async_extract_config_entry_ids(call))
if not reload_entries:
raise ValueError("There were no matching config entries to reload")
await asyncio.gather(
diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py
index b7e420dedde..135e6cdd376 100644
--- a/homeassistant/components/homeassistant/exposed_entities.py
+++ b/homeassistant/components/homeassistant/exposed_entities.py
@@ -406,7 +406,7 @@ def ws_expose_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Expose an entity to an assistant."""
- entity_ids: str = msg["entity_ids"]
+ entity_ids: list[str] = msg["entity_ids"]
if blocked := next(
(
diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py
index aec9b9cd06b..33ae659f0f6 100644
--- a/homeassistant/components/homeassistant/scene.py
+++ b/homeassistant/components/homeassistant/scene.py
@@ -272,7 +272,7 @@ async def async_setup_platform(
async def delete_service(call: ServiceCall) -> None:
"""Delete a dynamically created scene."""
- entity_ids = await async_extract_entity_ids(hass, call)
+ entity_ids = await async_extract_entity_ids(call)
for entity_id in entity_ids:
scene = platform.entities.get(entity_id)
diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml
index 372f4fa9955..b928ff0b851 100644
--- a/homeassistant/components/homeassistant/services.yaml
+++ b/homeassistant/components/homeassistant/services.yaml
@@ -32,15 +32,12 @@ set_location:
stop:
toggle:
target:
- entity: {}
turn_on:
target:
- entity: {}
turn_off:
target:
- entity: {}
update_entity:
fields:
@@ -53,8 +50,6 @@ update_entity:
reload_custom_templates:
reload_config_entry:
target:
- entity: {}
- device: {}
fields:
entry_id:
advanced: true
diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py
new file mode 100644
index 00000000000..7862f1b3422
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py
@@ -0,0 +1,71 @@
+"""The Home Assistant Connect ZBT-2 integration."""
+
+from __future__ import annotations
+
+import logging
+import os.path
+
+from homeassistant.components.usb import USBDevice, async_register_port_event_callback
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DEVICE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Home Assistant Connect ZBT-2 integration."""
+
+ @callback
+ def async_port_event_callback(
+ added: set[USBDevice], removed: set[USBDevice]
+ ) -> None:
+ """Handle USB port events."""
+ current_entries_by_path = {
+ entry.data[DEVICE]: entry
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ }
+
+ for device in added | removed:
+ path = device.device
+ entry = current_entries_by_path.get(path)
+
+ if entry is not None:
+ _LOGGER.debug(
+ "Device %r has changed state, reloading config entry %s",
+ path,
+ entry,
+ )
+ hass.config_entries.async_schedule_reload(entry.entry_id)
+
+ async_register_port_event_callback(hass, async_port_event_callback)
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a Home Assistant Connect ZBT-2 config entry."""
+
+ # Postpone loading the config entry if the device is missing
+ device_path = entry.data[DEVICE]
+ if not await hass.async_add_executor_job(os.path.exists, device_path):
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_disconnected",
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, ["update"])
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ await hass.config_entries.async_unload_platforms(entry, ["update"])
+ return True
diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py
new file mode 100644
index 00000000000..1d95601211e
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py
@@ -0,0 +1,209 @@
+"""Config flow for the Home Assistant Connect ZBT-2 integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any, Protocol
+
+from homeassistant.components import usb
+from homeassistant.components.homeassistant_hardware import firmware_config_flow
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+ ResetTarget,
+)
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigEntryBaseFlow,
+ ConfigFlowContext,
+ ConfigFlowResult,
+ OptionsFlow,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
+
+from .const import (
+ DEVICE,
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ HARDWARE_NAME,
+ MANUFACTURER,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ PID,
+ PRODUCT,
+ SERIAL_NUMBER,
+ VID,
+)
+from .util import get_usb_service_info
+
+_LOGGER = logging.getLogger(__name__)
+
+
+if TYPE_CHECKING:
+
+ class FirmwareInstallFlowProtocol(Protocol):
+ """Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
+
+ def _get_translation_placeholders(self) -> dict[str, str]:
+ return {}
+
+ async def _install_firmware_step(
+ self,
+ fw_update_url: str,
+ fw_type: str,
+ firmware_name: str,
+ expected_installed_firmware_type: ApplicationType,
+ step_id: str,
+ next_step_id: str,
+ ) -> ConfigFlowResult: ...
+
+else:
+ # Multiple inheritance with `Protocol` seems to break
+ FirmwareInstallFlowProtocol = object
+
+
+class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
+ """Mixin for Home Assistant Connect ZBT-2 firmware methods."""
+
+ context: ConfigFlowContext
+ BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
+
+ async def async_step_install_zigbee_firmware(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Install Zigbee firmware."""
+ return await self._install_firmware_step(
+ fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
+ fw_type="zbt2_zigbee_ncp",
+ firmware_name="Zigbee",
+ expected_installed_firmware_type=ApplicationType.EZSP,
+ step_id="install_zigbee_firmware",
+ next_step_id="pre_confirm_zigbee",
+ )
+
+ async def async_step_install_thread_firmware(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Install Thread firmware."""
+ return await self._install_firmware_step(
+ fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
+ fw_type="zbt2_openthread_rcp",
+ firmware_name="OpenThread",
+ expected_installed_firmware_type=ApplicationType.SPINEL,
+ step_id="install_thread_firmware",
+ next_step_id="finish_thread_installation",
+ )
+
+
+class HomeAssistantConnectZBT2ConfigFlow(
+ ZBT2FirmwareMixin,
+ firmware_config_flow.BaseFirmwareConfigFlow,
+ domain=DOMAIN,
+):
+ """Handle a config flow for Home Assistant Connect ZBT-2."""
+
+ VERSION = 1
+ MINOR_VERSION = 1
+ ZIGBEE_BAUDRATE = 460800
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Initialize the config flow."""
+ super().__init__(*args, **kwargs)
+
+ self._usb_info: UsbServiceInfo | None = None
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> OptionsFlow:
+ """Return the options flow."""
+ return HomeAssistantConnectZBT2OptionsFlowHandler(config_entry)
+
+ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
+ """Handle usb discovery."""
+ device = discovery_info.device
+ vid = discovery_info.vid
+ pid = discovery_info.pid
+ serial_number = discovery_info.serial_number
+ manufacturer = discovery_info.manufacturer
+ description = discovery_info.description
+ unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
+
+ device = discovery_info.device = await self.hass.async_add_executor_job(
+ usb.get_serial_by_id, discovery_info.device
+ )
+
+ try:
+ await self.async_set_unique_id(unique_id)
+ finally:
+ self._abort_if_unique_id_configured(updates={DEVICE: device})
+
+ self._usb_info = discovery_info
+
+ # Set parent class attributes
+ self._device = self._usb_info.device
+ self._hardware_name = HARDWARE_NAME
+
+ return await self.async_step_confirm()
+
+ def _async_flow_finished(self) -> ConfigFlowResult:
+ """Create the config entry."""
+ assert self._usb_info is not None
+ assert self._probed_firmware_info is not None
+
+ return self.async_create_entry(
+ title=HARDWARE_NAME,
+ data={
+ VID: self._usb_info.vid,
+ PID: self._usb_info.pid,
+ SERIAL_NUMBER: self._usb_info.serial_number,
+ MANUFACTURER: self._usb_info.manufacturer,
+ PRODUCT: self._usb_info.description,
+ DEVICE: self._usb_info.device,
+ FIRMWARE: self._probed_firmware_info.firmware_type.value,
+ FIRMWARE_VERSION: self._probed_firmware_info.firmware_version,
+ },
+ )
+
+
+class HomeAssistantConnectZBT2OptionsFlowHandler(
+ ZBT2FirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow
+):
+ """Zigbee and Thread options flow handlers."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Instantiate options flow."""
+ super().__init__(*args, **kwargs)
+
+ self._usb_info = get_usb_service_info(self.config_entry)
+ self._hardware_name = HARDWARE_NAME
+ self._device = self._usb_info.device
+
+ self._probed_firmware_info = FirmwareInfo(
+ device=self._device,
+ firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]),
+ firmware_version=self.config_entry.data[FIRMWARE_VERSION],
+ source="guess",
+ owners=[],
+ )
+
+ # Regenerate the translation placeholders
+ self._get_translation_placeholders()
+
+ def _async_flow_finished(self) -> ConfigFlowResult:
+ """Create the config entry."""
+ assert self._probed_firmware_info is not None
+
+ self.hass.config_entries.async_update_entry(
+ entry=self.config_entry,
+ data={
+ **self.config_entry.data,
+ FIRMWARE: self._probed_firmware_info.firmware_type.value,
+ FIRMWARE_VERSION: self._probed_firmware_info.firmware_version,
+ },
+ options=self.config_entry.options,
+ )
+
+ return self.async_create_entry(title="", data={})
diff --git a/homeassistant/components/homeassistant_connect_zbt2/const.py b/homeassistant/components/homeassistant_connect_zbt2/const.py
new file mode 100644
index 00000000000..c0b07a88687
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/const.py
@@ -0,0 +1,19 @@
+"""Constants for the Home Assistant Connect ZBT-2 integration."""
+
+DOMAIN = "homeassistant_connect_zbt2"
+
+NABU_CASA_FIRMWARE_RELEASES_URL = (
+ "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest"
+)
+
+FIRMWARE = "firmware"
+FIRMWARE_VERSION = "firmware_version"
+SERIAL_NUMBER = "serial_number"
+MANUFACTURER = "manufacturer"
+PRODUCT = "product"
+DESCRIPTION = "description"
+PID = "pid"
+VID = "vid"
+DEVICE = "device"
+
+HARDWARE_NAME = "Home Assistant Connect ZBT-2"
diff --git a/homeassistant/components/homeassistant_connect_zbt2/hardware.py b/homeassistant/components/homeassistant_connect_zbt2/hardware.py
new file mode 100644
index 00000000000..8367df6501d
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/hardware.py
@@ -0,0 +1,42 @@
+"""The Home Assistant Connect ZBT-2 hardware platform."""
+
+from __future__ import annotations
+
+from homeassistant.components.hardware.models import HardwareInfo, USBInfo
+from homeassistant.core import HomeAssistant, callback
+
+from .config_flow import HomeAssistantConnectZBT2ConfigFlow
+from .const import DOMAIN, HARDWARE_NAME, MANUFACTURER, PID, PRODUCT, SERIAL_NUMBER, VID
+
+DOCUMENTATION_URL = (
+ "https://support.nabucasa.com/hc/en-us/categories/"
+ "24734620813469-Home-Assistant-Connect-ZBT-1"
+)
+EXPECTED_ENTRY_VERSION = (
+ HomeAssistantConnectZBT2ConfigFlow.VERSION,
+ HomeAssistantConnectZBT2ConfigFlow.MINOR_VERSION,
+)
+
+
+@callback
+def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
+ """Return board info."""
+ entries = hass.config_entries.async_entries(DOMAIN)
+ return [
+ HardwareInfo(
+ board=None,
+ config_entries=[entry.entry_id],
+ dongle=USBInfo(
+ vid=entry.data[VID],
+ pid=entry.data[PID],
+ serial_number=entry.data[SERIAL_NUMBER],
+ manufacturer=entry.data[MANUFACTURER],
+ description=entry.data[PRODUCT],
+ ),
+ name=HARDWARE_NAME,
+ url=DOCUMENTATION_URL,
+ )
+ for entry in entries
+ # Ignore unmigrated config entries in the hardware page
+ if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION
+ ]
diff --git a/homeassistant/components/homeassistant_connect_zbt2/manifest.json b/homeassistant/components/homeassistant_connect_zbt2/manifest.json
new file mode 100644
index 00000000000..5d5c2996e47
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/manifest.json
@@ -0,0 +1,18 @@
+{
+ "domain": "homeassistant_connect_zbt2",
+ "name": "Home Assistant Connect ZBT-2",
+ "codeowners": ["@home-assistant/core"],
+ "config_flow": true,
+ "dependencies": ["hardware", "usb", "homeassistant_hardware"],
+ "documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
+ "integration_type": "hardware",
+ "quality_scale": "bronze",
+ "usb": [
+ {
+ "vid": "303A",
+ "pid": "4001",
+ "description": "*zbt-2*",
+ "known_devices": ["ZBT-2"]
+ }
+ ]
+}
diff --git a/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml b/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml
new file mode 100644
index 00000000000..a52b5abf0f1
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ action-setup:
+ status: done
+ comment: |
+ No actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions:
+ status: done
+ comment: |
+ Integration isn't set up by users.
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data:
+ status: exempt
+ comment: Nothing to store.
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/homeassistant_connect_zbt2/strings.json b/homeassistant/components/homeassistant_connect_zbt2/strings.json
new file mode 100644
index 00000000000..1fc7d4d70fb
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/strings.json
@@ -0,0 +1,238 @@
+{
+ "options": {
+ "step": {
+ "addon_not_installed": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::description%]",
+ "data": {
+ "enable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::data::enable_multi_pan%]"
+ }
+ },
+ "addon_installed_other_device": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]"
+ },
+ "addon_menu": {
+ "menu_options": {
+ "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]",
+ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
+ }
+ },
+ "change_channel": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]",
+ "data": {
+ "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]"
+ },
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::description%]"
+ },
+ "install_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
+ },
+ "install_thread_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
+ },
+ "install_zigbee_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
+ },
+ "notify_channel_change": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
+ },
+ "notify_unknown_multipan_user": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]"
+ },
+ "reconfigure_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
+ },
+ "start_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
+ },
+ "uninstall_addon": {
+ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]",
+ "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]",
+ "data": {
+ "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
+ }
+ },
+ "pick_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
+ "menu_options": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
+ }
+ },
+ "confirm_zigbee": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
+ },
+ "install_otbr_addon": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
+ },
+ "start_otbr_addon": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
+ },
+ "otbr_failed": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::description%]"
+ },
+ "confirm_otbr": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
+ },
+ "zigbee_installation_type": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
+ "menu_options": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
+ }
+ },
+ "zigbee_integration": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
+ "menu_options": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
+ }
+ }
+ },
+ "error": {
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
+ "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
+ "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
+ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
+ "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
+ "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
+ "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
+ "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]",
+ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
+ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
+ "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
+ "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
+ "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
+ "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
+ },
+ "progress": {
+ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
+ "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
+ "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
+ }
+ },
+ "config": {
+ "flow_title": "{model}",
+ "step": {
+ "install_thread_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
+ },
+ "install_zigbee_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
+ },
+ "pick_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
+ "menu_options": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
+ }
+ },
+ "confirm_zigbee": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
+ },
+ "install_otbr_addon": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
+ },
+ "start_otbr_addon": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
+ },
+ "otbr_failed": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::description%]"
+ },
+ "confirm_otbr": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
+ },
+ "zigbee_installation_type": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
+ "menu_options": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
+ }
+ },
+ "zigbee_integration": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
+ "menu_options": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
+ }
+ }
+ },
+ "abort": {
+ "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
+ "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
+ "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
+ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
+ "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
+ "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
+ "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
+ "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]",
+ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
+ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
+ "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
+ "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
+ "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
+ "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
+ },
+ "progress": {
+ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
+ "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
+ "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
+ }
+ },
+ "exceptions": {
+ "device_disconnected": {
+ "message": "The device is not plugged in"
+ }
+ }
+}
diff --git a/homeassistant/components/homeassistant_connect_zbt2/update.py b/homeassistant/components/homeassistant_connect_zbt2/update.py
new file mode 100644
index 00000000000..e6d66ca822d
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/update.py
@@ -0,0 +1,215 @@
+"""Home Assistant Connect ZBT-2 firmware update entity."""
+
+from __future__ import annotations
+
+import logging
+
+import aiohttp
+
+from homeassistant.components.homeassistant_hardware.coordinator import (
+ FirmwareUpdateCoordinator,
+)
+from homeassistant.components.homeassistant_hardware.update import (
+ BaseFirmwareUpdateEntity,
+ FirmwareUpdateEntityDescription,
+)
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+ ResetTarget,
+)
+from homeassistant.components.update import UpdateDeviceClass
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import (
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ HARDWARE_NAME,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ SERIAL_NUMBER,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+FIRMWARE_ENTITY_DESCRIPTIONS: dict[
+ ApplicationType | None, FirmwareUpdateEntityDescription
+] = {
+ ApplicationType.EZSP: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split(" ", 1)[0],
+ fw_type="zbt2_zigbee_ncp",
+ version_key="ezsp_version",
+ expected_firmware_type=ApplicationType.EZSP,
+ firmware_name="EmberZNet Zigbee",
+ ),
+ ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0],
+ fw_type="zbt2_openthread_rcp",
+ version_key="ot_rcp_version",
+ expected_firmware_type=ApplicationType.SPINEL,
+ firmware_name="OpenThread RCP",
+ ),
+ ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None, # We don't want to update the bootloader
+ version_key="gecko_bootloader_version",
+ expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
+ firmware_name="Gecko Bootloader",
+ ),
+ None: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None,
+ version_key=None,
+ expected_firmware_type=None,
+ firmware_name=None,
+ ),
+}
+
+
+def _async_create_update_entity(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ session: aiohttp.ClientSession,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> FirmwareUpdateEntity:
+ """Create an update entity that handles firmware type changes."""
+ firmware_type = config_entry.data[FIRMWARE]
+
+ try:
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
+ ApplicationType(firmware_type)
+ ]
+ except (KeyError, ValueError):
+ _LOGGER.debug(
+ "Unknown firmware type %r, using default entity description", firmware_type
+ )
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
+
+ entity = FirmwareUpdateEntity(
+ device=config_entry.data["device"],
+ config_entry=config_entry,
+ update_coordinator=FirmwareUpdateCoordinator(
+ hass,
+ config_entry,
+ session,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ ),
+ entity_description=entity_description,
+ )
+
+ def firmware_type_changed(
+ old_type: ApplicationType | None, new_type: ApplicationType | None
+ ) -> None:
+ """Replace the current entity when the firmware type changes."""
+ er.async_get(hass).async_remove(entity.entity_id)
+ async_add_entities(
+ [
+ _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+ ]
+ )
+
+ entity.async_on_remove(
+ entity.add_firmware_type_changed_callback(firmware_type_changed)
+ )
+
+ return entity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the firmware update config entry."""
+ session = async_get_clientsession(hass)
+ entity = _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+
+ async_add_entities([entity])
+
+
+class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
+ """Connect ZBT-2 firmware update entity."""
+
+ bootloader_reset_methods = [ResetTarget.RTS_DTR]
+
+ def __init__(
+ self,
+ device: str,
+ config_entry: ConfigEntry,
+ update_coordinator: FirmwareUpdateCoordinator,
+ entity_description: FirmwareUpdateEntityDescription,
+ ) -> None:
+ """Initialize the Connect ZBT-2 firmware update entity."""
+ super().__init__(device, config_entry, update_coordinator, entity_description)
+
+ serial_number = self._config_entry.data[SERIAL_NUMBER]
+
+ self._attr_unique_id = f"{serial_number}_{self.entity_description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, serial_number)},
+ name=f"{HARDWARE_NAME} ({serial_number})",
+ model=HARDWARE_NAME,
+ manufacturer="Nabu Casa",
+ serial_number=serial_number,
+ )
+
+ # Use the cached firmware info if it exists
+ if self._config_entry.data[FIRMWARE] is not None:
+ self._current_firmware_info = FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]),
+ firmware_version=self._config_entry.data[FIRMWARE_VERSION],
+ owners=[],
+ source="homeassistant_connect_zbt2",
+ )
+
+ def _update_attributes(self) -> None:
+ """Recompute the attributes of the entity."""
+ super()._update_attributes()
+
+ assert self.device_entry is not None
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_update_device(
+ device_id=self.device_entry.id,
+ sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
+ )
+
+ @callback
+ def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
+ """Handle updated firmware info being pushed by an integration."""
+ self.hass.config_entries.async_update_entry(
+ self._config_entry,
+ data={
+ **self._config_entry.data,
+ FIRMWARE: firmware_info.firmware_type,
+ FIRMWARE_VERSION: firmware_info.firmware_version,
+ },
+ )
+ super()._firmware_info_callback(firmware_info)
diff --git a/homeassistant/components/homeassistant_connect_zbt2/util.py b/homeassistant/components/homeassistant_connect_zbt2/util.py
new file mode 100644
index 00000000000..ebd6f33a8a8
--- /dev/null
+++ b/homeassistant/components/homeassistant_connect_zbt2/util.py
@@ -0,0 +1,22 @@
+"""Utility functions for Home Assistant Connect ZBT-2 integration."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo:
+ """Return UsbServiceInfo."""
+ return UsbServiceInfo(
+ device=config_entry.data["device"],
+ vid=config_entry.data["vid"],
+ pid=config_entry.data["pid"],
+ serial_number=config_entry.data["serial_number"],
+ manufacturer=config_entry.data["manufacturer"],
+ description=config_entry.data["product"],
+ )
diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
index 3263b091ad5..284e7611f2f 100644
--- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
+++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
+from enum import StrEnum
import logging
from typing import Any
@@ -23,10 +24,11 @@ from homeassistant.config_entries import (
ConfigEntryBaseFlow,
ConfigFlow,
ConfigFlowResult,
+ FlowType,
OptionsFlow,
)
from homeassistant.core import callback
-from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.data_entry_flow import AbortFlow, progress_step
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
@@ -37,6 +39,7 @@ from .util import (
FirmwareInfo,
OwningAddon,
OwningIntegration,
+ ResetTarget,
async_flash_silabs_firmware,
get_otbr_addon_manager,
guess_firmware_info,
@@ -48,13 +51,39 @@ _LOGGER = logging.getLogger(__name__)
STEP_PICK_FIRMWARE_THREAD = "pick_firmware_thread"
STEP_PICK_FIRMWARE_ZIGBEE = "pick_firmware_zigbee"
+STEP_PICK_FIRMWARE_THREAD_MIGRATE = "pick_firmware_thread_migrate"
+STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE = "pick_firmware_zigbee_migrate"
+
+
+class PickedFirmwareType(StrEnum):
+ """Firmware types that can be picked."""
+
+ THREAD = "thread"
+ ZIGBEE = "zigbee"
+
+
+class ZigbeeFlowStrategy(StrEnum):
+ """Zigbee setup strategies that can be picked."""
+
+ ADVANCED = "advanced"
+ RECOMMENDED = "recommended"
+
+
+class ZigbeeIntegration(StrEnum):
+ """Zigbee integrations that can be picked."""
+
+ OTHER = "other"
+ ZHA = "zha"
class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
- _failed_addon_name: str
- _failed_addon_reason: str
+ ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
+ BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
+
+ _picked_firmware_type: PickedFirmwareType
+ _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Instantiate base flow."""
@@ -63,11 +92,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self._probed_firmware_info: FirmwareInfo | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
+ self._zigbee_integration = ZigbeeIntegration.ZHA
- self.addon_install_task: asyncio.Task | None = None
- self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None
- self.firmware_install_task: asyncio.Task | None = None
+ self.firmware_install_task: asyncio.Task[None] | None = None
self.installing_firmware_name: str | None = None
def _get_translation_placeholders(self) -> dict[str, str]:
@@ -105,43 +133,31 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread or Zigbee firmware."""
+ # Determine if ZHA or Thread are already configured to present migrate options
+ zha_entries = self.hass.config_entries.async_entries(
+ ZHA_DOMAIN, include_ignore=False
+ )
+ otbr_entries = self.hass.config_entries.async_entries(
+ OTBR_DOMAIN, include_ignore=False
+ )
+
return self.async_show_menu(
step_id="pick_firmware",
menu_options=[
- STEP_PICK_FIRMWARE_ZIGBEE,
- STEP_PICK_FIRMWARE_THREAD,
+ (
+ STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE
+ if zha_entries
+ else STEP_PICK_FIRMWARE_ZIGBEE
+ ),
+ (
+ STEP_PICK_FIRMWARE_THREAD_MIGRATE
+ if otbr_entries
+ else STEP_PICK_FIRMWARE_THREAD
+ ),
],
description_placeholders=self._get_translation_placeholders(),
)
- async def _probe_firmware_info(
- self,
- probe_methods: tuple[ApplicationType, ...] = (
- # We probe in order of frequency: Zigbee, Thread, then multi-PAN
- ApplicationType.GECKO_BOOTLOADER,
- ApplicationType.EZSP,
- ApplicationType.SPINEL,
- ApplicationType.CPC,
- ),
- ) -> bool:
- """Probe the firmware currently on the device."""
- assert self._device is not None
-
- self._probed_firmware_info = await probe_silabs_firmware_info(
- self._device,
- probe_methods=probe_methods,
- )
-
- return (
- self._probed_firmware_info is not None
- and self._probed_firmware_info.firmware_type
- in (
- ApplicationType.EZSP,
- ApplicationType.SPINEL,
- ApplicationType.CPC,
- )
- )
-
async def _install_firmware_step(
self,
fw_update_url: str,
@@ -151,91 +167,17 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
- assert self._device is not None
-
+ """Show progress dialog for installing firmware."""
if not self.firmware_install_task:
- # Keep track of the firmware we're working with, for error messages
- self.installing_firmware_name = firmware_name
-
- # Installing new firmware is only truly required if the wrong type is
- # installed: upgrading to the latest release of the current firmware type
- # isn't strictly necessary for functionality.
- firmware_install_required = self._probed_firmware_info is None or (
- self._probed_firmware_info.firmware_type
- != expected_installed_firmware_type
- )
-
- session = async_get_clientsession(self.hass)
- client = FirmwareUpdateClient(fw_update_url, session)
-
- try:
- manifest = await client.async_update_data()
- fw_manifest = next(
- fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
- )
- except (StopIteration, TimeoutError, ClientError, ManifestMissing):
- _LOGGER.warning(
- "Failed to fetch firmware update manifest", exc_info=True
- )
-
- # Not having internet access should not prevent setup
- if not firmware_install_required:
- _LOGGER.debug(
- "Skipping firmware upgrade due to index download failure"
- )
- return self.async_show_progress_done(next_step_id=next_step_id)
-
- return self.async_show_progress_done(
- next_step_id="firmware_download_failed"
- )
-
- if not firmware_install_required:
- assert self._probed_firmware_info is not None
-
- # Make sure we do not downgrade the firmware
- fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
- fw_version = fw_metadata.get_public_version()
- probed_fw_version = Version(self._probed_firmware_info.firmware_version)
-
- if probed_fw_version >= fw_version:
- _LOGGER.debug(
- "Not downgrading firmware, installed %s is newer than available %s",
- probed_fw_version,
- fw_version,
- )
- return self.async_show_progress_done(next_step_id=next_step_id)
-
- try:
- fw_data = await client.async_fetch_firmware(fw_manifest)
- except (TimeoutError, ClientError, ValueError):
- _LOGGER.warning("Failed to fetch firmware update", exc_info=True)
-
- # If we cannot download new firmware, we shouldn't block setup
- if not firmware_install_required:
- _LOGGER.debug(
- "Skipping firmware upgrade due to image download failure"
- )
- return self.async_show_progress_done(next_step_id=next_step_id)
-
- # Otherwise, fail
- return self.async_show_progress_done(
- next_step_id="firmware_download_failed"
- )
-
self.firmware_install_task = self.hass.async_create_task(
- async_flash_silabs_firmware(
- hass=self.hass,
- device=self._device,
- fw_data=fw_data,
- expected_installed_firmware_type=expected_installed_firmware_type,
- bootloader_reset_type=None,
- progress_callback=lambda offset, total: self.async_update_progress(
- offset / total
- ),
+ self._install_firmware(
+ fw_update_url,
+ fw_type,
+ firmware_name,
+ expected_installed_firmware_type,
),
- f"Flash {firmware_name} firmware",
+ f"Install {firmware_name} firmware",
)
-
if not self.firmware_install_task.done():
return self.async_show_progress(
step_id=step_id,
@@ -249,12 +191,128 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
try:
await self.firmware_install_task
+ except AbortFlow as err:
+ return self.async_show_progress_done(
+ next_step_id=err.reason,
+ )
except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed")
+ finally:
+ self.firmware_install_task = None
return self.async_show_progress_done(next_step_id=next_step_id)
+ async def _install_firmware(
+ self,
+ fw_update_url: str,
+ fw_type: str,
+ firmware_name: str,
+ expected_installed_firmware_type: ApplicationType,
+ ) -> None:
+ """Install firmware."""
+ assert self._device is not None
+
+ # Keep track of the firmware we're working with, for error messages
+ self.installing_firmware_name = firmware_name
+
+ # Installing new firmware is only truly required if the wrong type is
+ # installed: upgrading to the latest release of the current firmware type
+ # isn't strictly necessary for functionality.
+ self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
+
+ firmware_install_required = self._probed_firmware_info is None or (
+ self._probed_firmware_info.firmware_type != expected_installed_firmware_type
+ )
+
+ session = async_get_clientsession(self.hass)
+ client = FirmwareUpdateClient(fw_update_url, session)
+
+ try:
+ manifest = await client.async_update_data()
+ fw_manifest = next(
+ fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
+ )
+ except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
+ _LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
+
+ # Not having internet access should not prevent setup
+ if not firmware_install_required:
+ _LOGGER.debug("Skipping firmware upgrade due to index download failure")
+ return
+
+ raise AbortFlow(reason="firmware_download_failed") from err
+
+ if not firmware_install_required:
+ assert self._probed_firmware_info is not None
+
+ # Make sure we do not downgrade the firmware
+ fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
+ fw_version = fw_metadata.get_public_version()
+ probed_fw_version = Version(self._probed_firmware_info.firmware_version)
+
+ if probed_fw_version >= fw_version:
+ _LOGGER.debug(
+ "Not downgrading firmware, installed %s is newer than available %s",
+ probed_fw_version,
+ fw_version,
+ )
+ return
+
+ try:
+ fw_data = await client.async_fetch_firmware(fw_manifest)
+ except (TimeoutError, ClientError, ValueError) as err:
+ _LOGGER.warning("Failed to fetch firmware update", exc_info=True)
+
+ # If we cannot download new firmware, we shouldn't block setup
+ if not firmware_install_required:
+ _LOGGER.debug("Skipping firmware upgrade due to image download failure")
+ return
+
+ # Otherwise, fail
+ raise AbortFlow(reason="firmware_download_failed") from err
+
+ self._probed_firmware_info = await async_flash_silabs_firmware(
+ hass=self.hass,
+ device=self._device,
+ fw_data=fw_data,
+ expected_installed_firmware_type=expected_installed_firmware_type,
+ bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
+ progress_callback=lambda offset, total: self.async_update_progress(
+ offset / total
+ ),
+ )
+
+ async def _configure_and_start_otbr_addon(self) -> None:
+ """Configure and start the OTBR addon."""
+ otbr_manager = get_otbr_addon_manager(self.hass)
+ addon_info = await self._async_get_addon_info(otbr_manager)
+
+ assert self._device is not None
+ new_addon_config = {
+ **addon_info.options,
+ "device": self._device,
+ "baudrate": 460800,
+ "flow_control": True,
+ "autoflash_firmware": False,
+ }
+
+ _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
+
+ try:
+ await otbr_manager.async_set_addon_options(new_addon_config)
+ except AddonError as err:
+ _LOGGER.error(err)
+ raise AbortFlow(
+ "addon_set_config_failed",
+ description_placeholders={
+ **self._get_translation_placeholders(),
+ "addon_name": otbr_manager.addon_name,
+ },
+ ) from err
+
+ await otbr_manager.async_start_addon_waiting()
+
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -281,83 +339,79 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
)
- async def async_step_pick_firmware_zigbee(
+ async def async_step_unsupported_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Pick Zigbee firmware."""
- if not await self._probe_firmware_info():
- return self.async_abort(
- reason="unsupported_firmware",
- description_placeholders=self._get_translation_placeholders(),
- )
-
- return await self.async_step_install_zigbee_firmware()
-
- async def async_step_install_zigbee_firmware(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Install Zigbee firmware."""
- raise NotImplementedError
-
- async def async_step_addon_operation_failed(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Abort when add-on installation or start failed."""
+ """Abort when unsupported firmware is detected."""
return self.async_abort(
- reason=self._failed_addon_reason,
- description_placeholders={
- **self._get_translation_placeholders(),
- "addon_name": self._failed_addon_name,
- },
+ reason="unsupported_firmware",
+ description_placeholders=self._get_translation_placeholders(),
)
- async def async_step_pre_confirm_zigbee(
+ async def async_step_zigbee_installation_type(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Pre-confirm Zigbee setup."""
-
- # This step is necessary to prevent `user_input` from being passed through
- return await self.async_step_confirm_zigbee()
-
- async def async_step_confirm_zigbee(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Confirm Zigbee setup."""
- assert self._device is not None
- assert self._hardware_name is not None
-
- if user_input is None:
- return self.async_show_form(
- step_id="confirm_zigbee",
- description_placeholders=self._get_translation_placeholders(),
- )
-
- if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
- return self.async_abort(
- reason="unsupported_firmware",
- description_placeholders=self._get_translation_placeholders(),
- )
-
- await self.hass.config_entries.flow.async_init(
- ZHA_DOMAIN,
- context={"source": "hardware"},
- data={
- "name": self._hardware_name,
- "port": {
- "path": self._device,
- "baudrate": 115200,
- "flow_control": "hardware",
- },
- "radio_type": "ezsp",
- },
+ """Handle the installation type step."""
+ return self.async_show_menu(
+ step_id="zigbee_installation_type",
+ menu_options=[
+ "zigbee_intent_recommended",
+ "zigbee_intent_custom",
+ ],
)
- return self._async_flow_finished()
+ async def async_step_zigbee_intent_recommended(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Select recommended installation type."""
+ self._zigbee_integration = ZigbeeIntegration.ZHA
+ self._zigbee_flow_strategy = ZigbeeFlowStrategy.RECOMMENDED
+ return await self._async_continue_picked_firmware()
- async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
- """Ensure the OTBR addon is set up and not running."""
+ async def async_step_zigbee_intent_custom(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Select custom installation type."""
+ self._zigbee_flow_strategy = ZigbeeFlowStrategy.ADVANCED
+ return await self.async_step_zigbee_integration()
- # We install the OTBR addon no matter what, since it is required to use Thread
+ async def async_step_zigbee_integration(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Select Zigbee integration."""
+ return self.async_show_menu(
+ step_id="zigbee_integration",
+ menu_options=[
+ "zigbee_integration_zha",
+ "zigbee_integration_other",
+ ],
+ )
+
+ async def async_step_zigbee_integration_zha(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Select ZHA integration."""
+ self._zigbee_integration = ZigbeeIntegration.ZHA
+ return await self._async_continue_picked_firmware()
+
+ async def async_step_zigbee_integration_other(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Select other Zigbee integration."""
+ self._zigbee_integration = ZigbeeIntegration.OTHER
+ return await self._async_continue_picked_firmware()
+
+ async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
+ """Continue to the picked firmware step."""
+ if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
+ return await self.async_step_install_zigbee_firmware()
+
+ return await self.async_step_install_thread_firmware()
+
+ async def async_step_finish_thread_installation(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Finish Thread installation by starting the OTBR addon."""
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
@@ -371,36 +425,80 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.RUNNING:
- # We only fail setup if we have an instance of OTBR running *and* it's
- # pointing to different hardware
- if addon_info.options["device"] != self._device:
- return self.async_abort(
- reason="otbr_addon_already_running",
- description_placeholders={
- **self._get_translation_placeholders(),
- "addon_name": otbr_manager.addon_name,
- },
- )
-
- # Otherwise, stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
- return None
+ return await self.async_step_start_otbr_addon()
+
+ async def async_step_pick_firmware_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Zigbee firmware."""
+ self._picked_firmware_type = PickedFirmwareType.ZIGBEE
+ return await self.async_step_zigbee_installation_type()
+
+ async def async_step_pick_firmware_zigbee_migrate(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Zigbee firmware. Migration is automatic."""
+ return await self.async_step_pick_firmware_zigbee()
+
+ async def async_step_install_zigbee_firmware(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Install Zigbee firmware."""
+ raise NotImplementedError
+
+ async def async_step_pre_confirm_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pre-confirm Zigbee setup."""
+
+ # This step is necessary to prevent `user_input` from being passed through
+ return await self.async_step_continue_zigbee()
+
+ async def async_step_continue_zigbee(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Continue Zigbee setup."""
+ assert self._device is not None
+ assert self._hardware_name is not None
+
+ if self._zigbee_integration == ZigbeeIntegration.OTHER:
+ return self._async_flow_finished()
+
+ result = await self.hass.config_entries.flow.async_init(
+ ZHA_DOMAIN,
+ context={"source": "hardware"},
+ data={
+ "name": self._hardware_name,
+ "port": {
+ "path": self._device,
+ "baudrate": self.ZIGBEE_BAUDRATE,
+ "flow_control": "hardware",
+ },
+ "radio_type": "ezsp",
+ "flow_strategy": self._zigbee_flow_strategy,
+ },
+ )
+ return self._continue_zha_flow(result)
+
+ @callback
+ def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
+ """Continue the ZHA flow."""
+ raise NotImplementedError
async def async_step_pick_firmware_thread(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
- if not await self._probe_firmware_info():
- return self.async_abort(
- reason="unsupported_firmware",
- description_placeholders=self._get_translation_placeholders(),
- )
+ self._picked_firmware_type = PickedFirmwareType.THREAD
+ return await self._async_continue_picked_firmware()
- if result := await self._ensure_thread_addon_setup():
- return result
-
- return await self.async_step_install_thread_firmware()
+ async def async_step_pick_firmware_thread_migrate(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Pick Thread firmware. Migration is automatic."""
+ return await self.async_step_pick_firmware_thread()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
@@ -408,6 +506,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware."""
raise NotImplementedError
+ @progress_step(
+ description_placeholders=lambda self: {
+ **self._get_translation_placeholders(),
+ "addon_name": get_otbr_addon_manager(self.hass).addon_name,
+ }
+ )
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -417,103 +521,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("OTBR addon info: %s", addon_info)
- if not self.addon_install_task:
- self.addon_install_task = self.hass.async_create_task(
- addon_manager.async_install_addon_waiting(),
- "OTBR addon install",
- )
-
- if not self.addon_install_task.done():
- return self.async_show_progress(
- step_id="install_otbr_addon",
- progress_action="install_addon",
+ try:
+ await addon_manager.async_install_addon_waiting()
+ except AddonError as err:
+ _LOGGER.error(err)
+ raise AbortFlow(
+ "addon_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
- progress_task=self.addon_install_task,
- )
+ ) from err
- try:
- await self.addon_install_task
- except AddonError as err:
- _LOGGER.error(err)
- self._failed_addon_name = addon_manager.addon_name
- self._failed_addon_reason = "addon_install_failed"
- return self.async_show_progress_done(next_step_id="addon_operation_failed")
- finally:
- self.addon_install_task = None
-
- return self.async_show_progress_done(next_step_id="install_thread_firmware")
+ return await self.async_step_finish_thread_installation()
+ @progress_step(
+ description_placeholders=lambda self: {
+ **self._get_translation_placeholders(),
+ "addon_name": get_otbr_addon_manager(self.hass).addon_name,
+ }
+ )
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
- otbr_manager = get_otbr_addon_manager(self.hass)
-
- if not self.addon_start_task:
- # Before we start the addon, confirm that the correct firmware is running
- # and populate `self._probed_firmware_info` with the correct information
- if not await self._probe_firmware_info(
- probe_methods=(ApplicationType.SPINEL,)
- ):
- return self.async_abort(
- reason="unsupported_firmware",
- description_placeholders=self._get_translation_placeholders(),
- )
-
- addon_info = await self._async_get_addon_info(otbr_manager)
-
- assert self._device is not None
- new_addon_config = {
- **addon_info.options,
- "device": self._device,
- "baudrate": 460800,
- "flow_control": True,
- "autoflash_firmware": False,
- }
-
- _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
-
- try:
- await otbr_manager.async_set_addon_options(new_addon_config)
- except AddonError as err:
- _LOGGER.error(err)
- raise AbortFlow(
- "addon_set_config_failed",
- description_placeholders={
- **self._get_translation_placeholders(),
- "addon_name": otbr_manager.addon_name,
- },
- ) from err
-
- self.addon_start_task = self.hass.async_create_task(
- otbr_manager.async_start_addon_waiting()
- )
-
- if not self.addon_start_task.done():
- return self.async_show_progress(
- step_id="start_otbr_addon",
- progress_action="start_otbr_addon",
+ try:
+ await self._configure_and_start_otbr_addon()
+ except AddonError as err:
+ _LOGGER.error(err)
+ raise AbortFlow(
+ "addon_start_failed",
description_placeholders={
**self._get_translation_placeholders(),
- "addon_name": otbr_manager.addon_name,
+ "addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
- progress_task=self.addon_start_task,
- )
+ ) from err
- try:
- await self.addon_start_task
- except (AddonError, AbortFlow) as err:
- _LOGGER.error(err)
- self._failed_addon_name = otbr_manager.addon_name
- self._failed_addon_reason = "addon_start_failed"
- return self.async_show_progress_done(next_step_id="addon_operation_failed")
- finally:
- self.addon_start_task = None
-
- return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
+ return await self.async_step_pre_confirm_otbr()
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None
@@ -521,20 +565,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Pre-confirm OTBR setup."""
# This step is necessary to prevent `user_input` from being passed through
- return await self.async_step_confirm_otbr()
-
- async def async_step_confirm_otbr(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Confirm OTBR setup."""
- assert self._device is not None
-
- if user_input is None:
- return self.async_show_form(
- step_id="confirm_otbr",
- description_placeholders=self._get_translation_placeholders(),
- )
-
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()
@@ -572,6 +602,21 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
return await self.async_step_pick_firmware()
+ @callback
+ def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
+ """Continue the ZHA flow."""
+ next_flow_id = zha_result["flow_id"]
+
+ result = self._async_flow_finished()
+ return (
+ self.async_create_entry(
+ title=result["title"] or self._hardware_name,
+ data=result["data"],
+ next_flow=(FlowType.CONFIG_FLOW, next_flow_id),
+ )
+ | result # update all items with the child result
+ )
+
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
@@ -629,3 +674,10 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
)
return await super().async_step_pick_firmware_thread(user_input)
+
+ @callback
+ def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
+ """Continue the ZHA flow."""
+ # The options flow cannot return a next_flow yet, so we just finish here.
+ # The options flow should be changed to a reconfigure flow.
+ return self._async_flow_finished()
diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json
index cf9acf14a5d..192aecc93bf 100644
--- a/homeassistant/components/homeassistant_hardware/manifest.json
+++ b/homeassistant/components/homeassistant_hardware/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
- "universal-silabs-flasher==0.0.31",
+ "universal-silabs-flasher==0.0.35",
"ha-silabs-firmware-client==0.2.0"
]
}
diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json
index da2374de57b..07ed06761fe 100644
--- a/homeassistant/components/homeassistant_hardware/strings.json
+++ b/homeassistant/components/homeassistant_hardware/strings.json
@@ -3,11 +3,19 @@
"options": {
"step": {
"pick_firmware": {
- "title": "Pick your firmware",
- "description": "Let's get started with setting up your {model}. Do you want to use it to set up a Zigbee or Thread network?",
+ "title": "Pick your protocol",
+ "description": "You can use your {model} for a Zigbee or Thread network. Please check what type of devices you want to add to Home Assistant. You can always change this later.",
"menu_options": {
- "pick_firmware_zigbee": "Zigbee",
- "pick_firmware_thread": "Thread"
+ "pick_firmware_zigbee": "Use as Zigbee adapter",
+ "pick_firmware_thread": "Use as Thread adapter",
+ "pick_firmware_zigbee_migrate": "Migrate Zigbee to a new adapter",
+ "pick_firmware_thread_migrate": "Migrate Thread to a new adapter"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "Most common protocol.",
+ "pick_firmware_thread": "Often used for Matter over Thread devices.",
+ "pick_firmware_zigbee_migrate": "This will move your Zigbee network to the new adapter.",
+ "pick_firmware_thread_migrate": "This will migrate your Thread Border Router to the new adapter."
}
},
"confirm_zigbee": {
@@ -15,12 +23,16 @@
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
},
"install_otbr_addon": {
- "title": "Installing OpenThread Border Router add-on",
- "description": "The OpenThread Border Router (OTBR) add-on is being installed."
+ "title": "Configuring Thread"
+ },
+ "install_thread_firmware": {
+ "title": "Updating adapter"
+ },
+ "install_zigbee_firmware": {
+ "title": "Updating adapter"
},
"start_otbr_addon": {
- "title": "Starting OpenThread Border Router add-on",
- "description": "The OpenThread Border Router (OTBR) add-on is now starting."
+ "title": "Configuring Thread"
},
"otbr_failed": {
"title": "Failed to set up OpenThread Border Router",
@@ -29,6 +41,29 @@
"confirm_otbr": {
"title": "OpenThread Border Router setup complete",
"description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration."
+ },
+ "zigbee_installation_type": {
+ "title": "Set up Zigbee",
+ "description": "Choose the installation type for the Zigbee adapter.",
+ "menu_options": {
+ "zigbee_intent_recommended": "Recommended installation",
+ "zigbee_intent_custom": "Custom"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "Automatically install and configure Zigbee.",
+ "zigbee_intent_custom": "Manually install and configure Zigbee, for example with Zigbee2MQTT."
+ }
+ },
+ "zigbee_integration": {
+ "title": "Select Zigbee method",
+ "menu_options": {
+ "zigbee_integration_zha": "Zigbee Home Automation",
+ "zigbee_integration_other": "Other"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "Lets Home Assistant control a Zigbee network.",
+ "zigbee_integration_other": "For example if you want to use the adapter with Zigbee2MQTT."
+ }
}
},
"abort": {
@@ -41,7 +76,9 @@
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
},
"progress": {
- "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
+ "install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
+ "install_otbr_addon": "Installing add-on",
+ "start_otbr_addon": "Starting add-on"
}
}
},
diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py
index 831d9f3f4da..81c02360bd2 100644
--- a/homeassistant/components/homeassistant_hardware/update.py
+++ b/homeassistant/components/homeassistant_hardware/update.py
@@ -22,7 +22,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback
-from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
+from .util import (
+ ApplicationType,
+ FirmwareInfo,
+ ResetTarget,
+ async_flash_silabs_firmware,
+)
_LOGGER = logging.getLogger(__name__)
@@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
- bootloader_reset_type: str | None = None
+ bootloader_reset_methods: list[ResetTarget] = []
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -268,7 +273,7 @@ class BaseFirmwareUpdateEntity(
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
- bootloader_reset_type=self.bootloader_reset_type,
+ bootloader_reset_methods=self.bootloader_reset_methods,
progress_callback=self._update_progress,
)
finally:
diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py
index d84f4f75ff7..278cc191516 100644
--- a/homeassistant/components/homeassistant_hardware/util.py
+++ b/homeassistant/components/homeassistant_hardware/util.py
@@ -4,13 +4,16 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
-from collections.abc import AsyncIterator, Callable, Iterable
+from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging
-from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
+from universal_silabs_flasher.const import (
+ ApplicationType as FlasherApplicationType,
+ ResetTarget as FlasherResetTarget,
+)
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
@@ -42,9 +45,9 @@ class ApplicationType(StrEnum):
"""Application type running on a device."""
GECKO_BOOTLOADER = "bootloader"
- CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
+ CPC = "cpc"
ROUTER = "router"
@classmethod
@@ -59,6 +62,18 @@ class ApplicationType(StrEnum):
return FlasherApplicationType(self.value)
+class ResetTarget(StrEnum):
+ """Methods to reset a device into bootloader mode."""
+
+ RTS_DTR = "rts_dtr"
+ BAUDRATE = "baudrate"
+ YELLOW = "yellow"
+
+ def as_flasher_reset_target(self) -> FlasherResetTarget:
+ """Convert the reset target enum into one compatible with USF."""
+ return FlasherResetTarget(self.value)
+
+
@singleton(OTBR_ADDON_MANAGER_DATA)
@callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
@@ -342,7 +357,7 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
- bootloader_reset_type: str | None = None,
+ bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
@@ -359,7 +374,9 @@ async def async_flash_silabs_firmware(
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
- bootloader_reset=bootloader_reset_type,
+ bootloader_reset=tuple(
+ m.as_flasher_reset_target() for m in bootloader_reset_methods
+ ),
)
async with AsyncExitStack() as stack:
diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py
index 197cb2ff2ce..7a9eff0b741 100644
--- a/homeassistant/components/homeassistant_sky_connect/config_flow.py
+++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py
@@ -106,7 +106,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
- next_step_id="start_otbr_addon",
+ next_step_id="finish_thread_installation",
)
diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json
index 13775d1f1eb..c2f02897b45 100644
--- a/homeassistant/components/homeassistant_sky_connect/strings.json
+++ b/homeassistant/components/homeassistant_sky_connect/strings.json
@@ -27,6 +27,12 @@
"install_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
},
+ "install_thread_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
+ },
+ "install_zigbee_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
+ },
"notify_channel_change": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
@@ -52,8 +58,16 @@
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
- "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -61,12 +75,10 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
},
"install_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
},
"start_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
"otbr_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
@@ -75,6 +87,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
+ },
+ "zigbee_installation_type": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
+ "menu_options": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
+ }
+ },
+ "zigbee_integration": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
+ "menu_options": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
+ }
}
},
"error": {
@@ -98,9 +133,10 @@
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
+ "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
}
},
"config": {
@@ -111,7 +147,15 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
- "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -119,12 +163,16 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
},
"install_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
+ },
+ "install_thread_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
+ },
+ "install_zigbee_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
},
"start_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
"otbr_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
@@ -133,6 +181,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
+ },
+ "zigbee_installation_type": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
+ "menu_options": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
+ }
+ },
+ "zigbee_integration": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
+ "menu_options": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
+ }
}
},
"abort": {
@@ -153,9 +224,10 @@
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
+ "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
}
},
"exceptions": {
diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py
index df69b6d40a2..eab9fc232a4 100644
--- a/homeassistant/components/homeassistant_sky_connect/update.py
+++ b/homeassistant/components/homeassistant_sky_connect/update.py
@@ -168,7 +168,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
- bootloader_reset_type = None
+ # The ZBT-1 does not have a hardware bootloader trigger
+ bootloader_reset_methods = []
def __init__(
self,
diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py
index db844d0b0e9..821ba48eee7 100644
--- a/homeassistant/components/homeassistant_yellow/config_flow.py
+++ b/homeassistant/components/homeassistant_yellow/config_flow.py
@@ -27,6 +27,8 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
+ ResetTarget,
+ probe_silabs_firmware_info,
)
from homeassistant.config_entries import (
SOURCE_HARDWARE,
@@ -82,6 +84,8 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
+ BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
+
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -92,7 +96,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
- next_step_id="confirm_zigbee",
+ next_step_id="pre_confirm_zigbee",
)
async def async_step_install_thread_firmware(
@@ -105,7 +109,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
- next_step_id="start_otbr_addon",
+ next_step_id="finish_thread_installation",
)
@@ -141,8 +145,10 @@ class HomeAssistantYellowConfigFlow(
self, data: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
+ assert self._device is not None
+
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
- await self._probe_firmware_info()
+ self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (
diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json
index d0c5e969d11..f25e2b6d2bd 100644
--- a/homeassistant/components/homeassistant_yellow/strings.json
+++ b/homeassistant/components/homeassistant_yellow/strings.json
@@ -35,6 +35,12 @@
"install_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
},
+ "install_thread_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]"
+ },
+ "install_zigbee_firmware": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]"
+ },
"notify_channel_change": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]"
@@ -75,8 +81,16 @@
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
"menu_options": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
- "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
+ },
+ "menu_option_descriptions": {
+ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
+ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
+ "pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
+ "pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
}
},
"confirm_zigbee": {
@@ -84,12 +98,10 @@
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
},
"install_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]"
},
"start_otbr_addon": {
- "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]",
- "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]"
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
},
"otbr_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]",
@@ -98,6 +110,29 @@
"confirm_otbr": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
+ },
+ "zigbee_installation_type": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
+ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
+ "menu_options": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
+ "zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
+ }
+ },
+ "zigbee_integration": {
+ "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
+ "menu_options": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
+ },
+ "menu_option_descriptions": {
+ "zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
+ "zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
+ }
}
},
"error": {
@@ -123,9 +158,10 @@
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
+ "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
+ "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
- "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
+ "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
}
},
"entity": {
diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py
index 7a6e2f19b1f..d86ac93a848 100644
--- a/homeassistant/components/homeassistant_yellow/update.py
+++ b/homeassistant/components/homeassistant_yellow/update.py
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
+ ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
@@ -173,7 +174,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
- bootloader_reset_type = "yellow" # Triggers a GPIO reset
+ bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
def __init__(
self,
diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py
index fd7371b31e4..74aa6e36884 100644
--- a/homeassistant/components/homee/alarm_control_panel.py
+++ b/homeassistant/components/homee/alarm_control_panel.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass
from pyHomee.const import AttributeChangedBy, AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, HomeeConfigEntry
from .entity import HomeeEntity
-from .helpers import get_name_for_enum
+from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
@@ -60,18 +60,29 @@ def get_supported_features(
return supported_features
+async def add_alarm_control_panel_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee alarm control panel entities."""
+ async_add_entities(
+ HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the alarm control panel component."""
+ """Add the homee platform for the alarm control panel component."""
- async_add_entities(
- HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
+ await setup_homee_platform(
+ add_alarm_control_panel_entities, async_add_entities, config_entry
)
diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py
index 3f5f5c46a29..10eb5ea9121 100644
--- a/homeassistant/components/homee/binary_sensor.py
+++ b/homeassistant/components/homee/binary_sensor.py
@@ -1,7 +1,7 @@
"""The Homee binary sensor platform."""
from pyHomee.const import AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -152,23 +153,34 @@ BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] =
}
-async def async_setup_entry(
- hass: HomeAssistant,
+async def add_binary_sensor_entities(
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
) -> None:
- """Add the Homee platform for the binary sensor component."""
-
- async_add_devices(
+ """Add homee binary sensor entities."""
+ async_add_entities(
HomeeBinarySensor(
attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type]
)
- for node in config_entry.runtime_data.nodes
+ for node in nodes
for attribute in node.attributes
if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable
)
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the homee platform for the binary sensor component."""
+
+ await setup_homee_platform(
+ add_binary_sensor_entities, async_add_entities, config_entry
+ )
+
+
class HomeeBinarySensor(HomeeEntity, BinarySensorEntity):
"""Representation of a Homee binary sensor."""
diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py
index 33a8b5f23c8..41dd111cf84 100644
--- a/homeassistant/components/homee/button.py
+++ b/homeassistant/components/homee/button.py
@@ -1,7 +1,7 @@
"""The homee button platform."""
from pyHomee.const import AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -39,19 +40,28 @@ BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
}
+async def add_button_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee button entities."""
+ async_add_entities(
+ HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the button component."""
+ """Add the homee platform for the button component."""
- async_add_entities(
- HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
- )
+ await setup_homee_platform(add_button_entities, async_add_entities, config_entry)
class HomeeButton(HomeeEntity, ButtonEntity):
diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py
index f6027522243..0aa3467f760 100644
--- a/homeassistant/components/homee/climate.py
+++ b/homeassistant/components/homee/climate.py
@@ -21,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL
from .entity import HomeeNodeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -31,18 +32,27 @@ ROOM_THERMOSTATS = {
}
+async def add_climate_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee climate entities."""
+ async_add_entities(
+ HomeeClimate(node, config_entry)
+ for node in nodes
+ if node.profile in CLIMATE_PROFILES
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the climate component."""
- async_add_devices(
- HomeeClimate(node, config_entry)
- for node in config_entry.runtime_data.nodes
- if node.profile in CLIMATE_PROFILES
- )
+ await setup_homee_platform(add_climate_entities, async_add_entities, config_entry)
class HomeeClimate(HomeeNodeEntity, ClimateEntity):
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
index 79a9b00ffba..b48d965512e 100644
--- a/homeassistant/components/homee/cover.py
+++ b/homeassistant/components/homee/cover.py
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeNodeEntity
+from .helpers import setup_homee_platform
_LOGGER = logging.getLogger(__name__)
@@ -77,18 +78,25 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
return COVER_DEVICE_PROFILES.get(node.profile)
+async def add_cover_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee cover entities."""
+ async_add_entities(
+ HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the cover integration."""
- async_add_devices(
- HomeeCover(node, config_entry)
- for node in config_entry.runtime_data.nodes
- if is_cover_node(node)
- )
+ await setup_homee_platform(add_cover_entities, async_add_entities, config_entry)
def is_cover_node(node: HomeeNode) -> bool:
diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py
index 73c315e8695..5c4fa0af380 100644
--- a/homeassistant/components/homee/event.py
+++ b/homeassistant/components/homee/event.py
@@ -1,7 +1,7 @@
"""The homee event platform."""
from pyHomee.const import AttributeType, NodeProfile
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.event import (
EventDeviceClass,
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -49,6 +50,22 @@ EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = {
}
+async def add_event_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee event entities."""
+ async_add_entities(
+ HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in EVENT_DESCRIPTIONS
+ and node.profile in REMOTE_PROFILES
+ and not attribute.editable
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
@@ -56,14 +73,7 @@ async def async_setup_entry(
) -> None:
"""Add event entities for homee."""
- async_add_entities(
- HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in EVENT_DESCRIPTIONS
- and node.profile in REMOTE_PROFILES
- and not attribute.editable
- )
+ await setup_homee_platform(add_event_entities, async_add_entities, config_entry)
class HomeeEvent(HomeeEntity, EventEntity):
diff --git a/homeassistant/components/homee/fan.py b/homeassistant/components/homee/fan.py
index d4694ee8d66..7904f008742 100644
--- a/homeassistant/components/homee/fan.py
+++ b/homeassistant/components/homee/fan.py
@@ -19,22 +19,32 @@ from homeassistant.util.scaling import int_states_in_range
from . import HomeeConfigEntry
from .const import DOMAIN, PRESET_AUTO, PRESET_MANUAL, PRESET_SUMMER
from .entity import HomeeNodeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
+async def add_fan_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee fan entities."""
+ async_add_entities(
+ HomeeFan(node, config_entry)
+ for node in nodes
+ if node.profile == NodeProfile.VENTILATION_CONTROL
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homee fan platform."""
- async_add_devices(
- HomeeFan(node, config_entry)
- for node in config_entry.runtime_data.nodes
- if node.profile == NodeProfile.VENTILATION_CONTROL
- )
+ await setup_homee_platform(add_fan_entities, async_add_entities, config_entry)
class HomeeFan(HomeeNodeEntity, FanEntity):
diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py
index b73b1ae2bc9..f9f675a631d 100644
--- a/homeassistant/components/homee/helpers.py
+++ b/homeassistant/components/homee/helpers.py
@@ -1,11 +1,42 @@
"""Helper functions for the homee custom component."""
+from collections.abc import Callable, Coroutine
from enum import IntEnum
import logging
+from typing import Any
+
+from pyHomee.model import HomeeNode
+
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
_LOGGER = logging.getLogger(__name__)
+async def setup_homee_platform(
+ add_platform_entities: Callable[
+ [HomeeConfigEntry, AddConfigEntryEntitiesCallback, list[HomeeNode]],
+ Coroutine[Any, Any, None],
+ ],
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ config_entry: HomeeConfigEntry,
+) -> None:
+ """Set up a homee platform."""
+ await add_platform_entities(
+ config_entry, async_add_entities, config_entry.runtime_data.nodes
+ )
+
+ async def add_device(node: HomeeNode, add: bool) -> None:
+ """Dynamically add entities."""
+ if add:
+ await add_platform_entities(config_entry, async_add_entities, [node])
+
+ config_entry.async_on_unload(
+ config_entry.runtime_data.add_nodes_listener(add_device)
+ )
+
+
def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None:
"""Return the enum item name for a given integer."""
try:
diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py
index 9c66764760e..3fbfcbeba22 100644
--- a/homeassistant/components/homee/light.py
+++ b/homeassistant/components/homee/light.py
@@ -24,6 +24,7 @@ from homeassistant.util.color import (
from . import HomeeConfigEntry
from .const import LIGHT_PROFILES
from .entity import HomeeNodeEntity
+from .helpers import setup_homee_platform
LIGHT_ATTRIBUTES = [
AttributeType.COLOR,
@@ -85,19 +86,28 @@ def decimal_to_rgb_list(color: float) -> list[int]:
]
+async def add_light_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee light entities."""
+ async_add_entities(
+ HomeeLight(node, light, config_entry)
+ for node in nodes
+ for light in get_light_attribute_sets(node)
+ if is_light_node(node)
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the light entity."""
+ """Add the homee platform for the light entity."""
- async_add_entities(
- HomeeLight(node, light, config_entry)
- for node in config_entry.runtime_data.nodes
- for light in get_light_attribute_sets(node)
- if is_light_node(node)
- )
+ await setup_homee_platform(add_light_entities, async_add_entities, config_entry)
class HomeeLight(HomeeNodeEntity, LightEntity):
diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py
index 8b3bf58040d..f061e2eefae 100644
--- a/homeassistant/components/homee/lock.py
+++ b/homeassistant/components/homee/lock.py
@@ -3,6 +3,7 @@
from typing import Any
from pyHomee.const import AttributeChangedBy, AttributeType
+from pyHomee.model import HomeeNode
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
@@ -10,24 +11,33 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
-from .helpers import get_name_for_enum
+from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
+async def add_lock_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee lock entities."""
+ async_add_entities(
+ HomeeLock(attribute, config_entry)
+ for node in nodes
+ for attribute in node.attributes
+ if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the lock component."""
+ """Add the homee platform for the lock component."""
- async_add_devices(
- HomeeLock(attribute, config_entry)
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
- )
+ await setup_homee_platform(add_lock_entities, async_add_entities, config_entry)
class HomeeLock(HomeeEntity, LockEntity):
diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json
index 35e89ec645a..4304239cf1c 100644
--- a/homeassistant/components/homee/manifest.json
+++ b/homeassistant/components/homee/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "silver",
- "requirements": ["pyHomee==1.2.10"],
+ "requirements": ["pyHomee==1.3.8"],
"zeroconf": [
{
"type": "_ssh._tcp.local.",
diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py
index 5b824f18851..2015f9953fb 100644
--- a/homeassistant/components/homee/number.py
+++ b/homeassistant/components/homee/number.py
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from pyHomee.const import AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.number import (
NumberDeviceClass,
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import HOMEE_UNIT_TO_HA_UNIT
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -136,19 +137,28 @@ NUMBER_DESCRIPTIONS = {
}
+async def add_number_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee number entities."""
+ async_add_entities(
+ HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the number component."""
+ """Add the homee platform for the number component."""
- async_add_entities(
- HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
- )
+ await setup_homee_platform(add_number_entities, async_add_entities, config_entry)
class HomeeNumber(HomeeEntity, NumberEntity):
diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml
index 5a8f987c1f9..f27876b1725 100644
--- a/homeassistant/components/homee/quality_scale.yaml
+++ b/homeassistant/components/homee/quality_scale.yaml
@@ -54,7 +54,7 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
- dynamic-devices: todo
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py
index 694d1bc7456..9466305c275 100644
--- a/homeassistant/components/homee/select.py
+++ b/homeassistant/components/homee/select.py
@@ -1,7 +1,7 @@
"""The Homee select platform."""
from pyHomee.const import AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
@@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -27,19 +28,28 @@ SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = {
}
+async def add_select_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee select entities."""
+ async_add_entities(
+ HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the select component."""
+ """Add the homee platform for the select component."""
- async_add_entities(
- HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
- )
+ await setup_homee_platform(add_select_entities, async_add_entities, config_entry)
class HomeeSelect(HomeeEntity, SelectEntity):
diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py
index f977f705eb8..71508c5d669 100644
--- a/homeassistant/components/homee/sensor.py
+++ b/homeassistant/components/homee/sensor.py
@@ -35,7 +35,7 @@ from .const import (
WINDOW_MAP_REVERSED,
)
from .entity import HomeeEntity, HomeeNodeEntity
-from .helpers import get_name_for_enum
+from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
@@ -304,16 +304,16 @@ def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
ent_reg = er.async_get(hass)
- devices: list[HomeeSensor | HomeeNodeSensor] = []
def add_deprecated_entity(
attribute: HomeeAttribute, description: HomeeSensorEntityDescription
- ) -> None:
+ ) -> 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)
@@ -325,7 +325,9 @@ async def async_setup_entry(
f"deprecated_entity_{entity_uid}",
)
elif entity_entry:
- devices.append(HomeeSensor(attribute, config_entry, description))
+ deprecated_entities.append(
+ HomeeSensor(attribute, config_entry, description)
+ )
if entity_used_in(hass, entity_id):
async_create_issue(
hass,
@@ -342,27 +344,42 @@ async def async_setup_entry(
"entity": entity_id,
},
)
+ return deprecated_entities
- for node in config_entry.runtime_data.nodes:
- # Node properties that are sensors.
- devices.extend(
- HomeeNodeSensor(node, config_entry, description)
- for description in NODE_SENSOR_DESCRIPTIONS
- )
+ async def add_sensor_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+ ) -> None:
+ """Add homee sensor entities."""
+ entities: list[HomeeSensor | HomeeNodeSensor] = []
- # Node attributes that are sensors.
- for attribute in node.attributes:
- if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
- add_deprecated_entity(attribute, SENSOR_DESCRIPTIONS[attribute.type])
- elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
- devices.append(
- HomeeSensor(
- attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
+ for node in nodes:
+ # Node properties that are sensors.
+ entities.extend(
+ HomeeNodeSensor(node, config_entry, description)
+ for description in NODE_SENSOR_DESCRIPTIONS
+ )
+
+ # 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]
+ )
)
- )
- if devices:
- async_add_devices(devices)
+ if entities:
+ async_add_entities(entities)
+
+ await setup_homee_platform(add_sensor_entities, async_add_entities, config_entry)
class HomeeSensor(HomeeEntity, SensorEntity):
diff --git a/homeassistant/components/homee/siren.py b/homeassistant/components/homee/siren.py
index da158c82f46..9970f396ef9 100644
--- a/homeassistant/components/homee/siren.py
+++ b/homeassistant/components/homee/siren.py
@@ -3,6 +3,7 @@
from typing import Any
from pyHomee.const import AttributeType
+from pyHomee.model import HomeeNode
from homeassistant.components.siren import SirenEntity, SirenEntityFeature
from homeassistant.core import HomeAssistant
@@ -10,23 +11,33 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
+async def add_siren_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee siren entities."""
+ async_add_entities(
+ HomeeSiren(attribute, config_entry)
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type == AttributeType.SIREN
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add siren entities for homee."""
- async_add_devices(
- HomeeSiren(attribute, config_entry)
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type == AttributeType.SIREN
- )
+ await setup_homee_platform(add_siren_entities, async_add_entities, config_entry)
class HomeeSiren(HomeeEntity, SirenEntity):
diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py
index 5e87a1b4002..b620cb55c26 100644
--- a/homeassistant/components/homee/switch.py
+++ b/homeassistant/components/homee/switch.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Any
from pyHomee.const import AttributeType, NodeProfile
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, LIGHT_PROFILES
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -65,27 +66,35 @@ SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = {
}
+async def add_switch_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee switch entities."""
+ async_add_entities(
+ HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
+ and not (
+ attribute.type == AttributeType.ON_OFF and node.profile in LIGHT_PROFILES
+ )
+ and not (
+ attribute.type == AttributeType.MANUAL_OPERATION
+ and node.profile in CLIMATE_PROFILES
+ )
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddConfigEntryEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform for the Homee component."""
- for node in config_entry.runtime_data.nodes:
- async_add_devices(
- HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
- for attribute in node.attributes
- if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
- and not (
- attribute.type == AttributeType.ON_OFF
- and node.profile in LIGHT_PROFILES
- )
- and not (
- attribute.type == AttributeType.MANUAL_OPERATION
- and node.profile in CLIMATE_PROFILES
- )
- )
+ await setup_homee_platform(add_switch_entities, async_add_entities, config_entry)
class HomeeSwitch(HomeeEntity, SwitchEntity):
diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py
index 995716d7ef8..64b1eac0efc 100644
--- a/homeassistant/components/homee/valve.py
+++ b/homeassistant/components/homee/valve.py
@@ -1,7 +1,7 @@
"""The Homee valve platform."""
from pyHomee.const import AttributeType
-from pyHomee.model import HomeeAttribute
+from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.valve import (
ValveDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
+from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -25,19 +26,28 @@ VALVE_DESCRIPTIONS = {
}
+async def add_valve_entities(
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ nodes: list[HomeeNode],
+) -> None:
+ """Add homee valve entities."""
+ async_add_entities(
+ HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
+ for node in nodes
+ for attribute in node.attributes
+ if attribute.type in VALVE_DESCRIPTIONS
+ )
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add the Homee platform for the valve component."""
+ """Add the homee platform for the valve component."""
- async_add_entities(
- HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
- for node in config_entry.runtime_data.nodes
- for attribute in node.attributes
- if attribute.type in VALVE_DESCRIPTIONS
- )
+ await setup_homee_platform(add_valve_entities, async_add_entities, config_entry)
class HomeeValve(HomeeEntity, ValveEntity):
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index 50b11265cf4..7c132a00a77 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -224,9 +224,8 @@ RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema(
)
-UNPAIR_SERVICE_SCHEMA = vol.All(
- vol.Schema(cv.ENTITY_SERVICE_FIELDS),
- cv.has_at_least_one_key(ATTR_DEVICE_ID),
+UNPAIR_SERVICE_SCHEMA = vol.Schema(
+ {vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [str])}
)
diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json
index 431de804023..4aaec4a9840 100644
--- a/homeassistant/components/homekit/manifest.json
+++ b/homeassistant/components/homekit/manifest.json
@@ -9,8 +9,8 @@
"iot_class": "local_push",
"loggers": ["pyhap"],
"requirements": [
- "HAP-python==4.9.2",
- "fnv-hash-fast==1.5.0",
+ "HAP-python==5.0.0",
+ "fnv-hash-fast==1.6.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],
diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml
index de271db0ad9..ffd17d1e8d7 100644
--- a/homeassistant/components/homekit/services.yaml
+++ b/homeassistant/components/homekit/services.yaml
@@ -2,10 +2,18 @@
reload:
reset_accessory:
- target:
- entity: {}
+ fields:
+ entity_id:
+ required: true
+ selector:
+ entity:
+ multiple: true
unpair:
- target:
- device:
- integration: homekit
+ fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ multiple: true
+ integration: homekit
diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json
index e6507c4a912..1ec897660a1 100644
--- a/homeassistant/components/homekit/strings.json
+++ b/homeassistant/components/homekit/strings.json
@@ -76,11 +76,23 @@
},
"reset_accessory": {
"name": "Reset accessory",
- "description": "Resets a HomeKit accessory."
+ "description": "Resets a HomeKit accessory.",
+ "fields": {
+ "entity_id": {
+ "name": "Entity",
+ "description": "Entity to reset."
+ }
+ }
},
"unpair": {
"name": "Unpair an accessory or bridge",
- "description": "Forcefully removes all pairings from an accessory to allow re-pairing. Use this action if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost."
+ "description": "Forcefully removes all pairings from an accessory to allow re-pairing. Use this action if the accessory is no longer responsive and you want to avoid deleting and re-adding the entry. Room locations and accessory preferences will be lost.",
+ "fields": {
+ "device_id": {
+ "name": "Device",
+ "description": "Device to unpair."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
index c011b8cd327..8a1d9e33051 100644
--- a/homeassistant/components/homekit/type_switches.py
+++ b/homeassistant/components/homekit/type_switches.py
@@ -17,6 +17,9 @@ from pyhap.const import (
from homeassistant.components import button, input_button
from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
+ CONF_MAX as INPUT_NUMBER_CONF_MAX,
+ CONF_MIN as INPUT_NUMBER_CONF_MIN,
+ CONF_STEP as INPUT_NUMBER_CONF_STEP,
DOMAIN as INPUT_NUMBER_DOMAIN,
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
)
@@ -65,6 +68,9 @@ from .const import (
CHAR_VALVE_TYPE,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
+ PROP_MAX_VALUE,
+ PROP_MIN_STEP,
+ PROP_MIN_VALUE,
SERV_OUTLET,
SERV_SWITCH,
SERV_VALVE,
@@ -94,6 +100,17 @@ VALVE_TYPE: dict[str, ValveInfo] = {
TYPE_VALVE: ValveInfo(CATEGORY_FAUCET, 0),
}
+VALVE_LINKED_DURATION_PROPERTIES = {
+ INPUT_NUMBER_CONF_MIN,
+ INPUT_NUMBER_CONF_MAX,
+ INPUT_NUMBER_CONF_STEP,
+}
+
+VALVE_DURATION_MIN_DEFAULT = 0
+VALVE_DURATION_MAX_DEFAULT = 3600
+VALVE_DURATION_STEP_DEFAULT = 1
+VALVE_REMAINING_TIME_MAX_DEFAULT = 60 * 60 * 48
+
ACTIVATE_ONLY_SWITCH_DOMAINS = {"button", "input_button", "scene", "script"}
@@ -312,6 +329,18 @@ class ValveBase(HomeAccessory):
CHAR_SET_DURATION,
value=self.get_duration(),
setter_callback=self.set_duration,
+ # Properties are set to match the linked duration entity configuration
+ properties={
+ PROP_MIN_VALUE: self._get_linked_duration_property(
+ INPUT_NUMBER_CONF_MIN, VALVE_DURATION_MIN_DEFAULT
+ ),
+ PROP_MAX_VALUE: self._get_linked_duration_property(
+ INPUT_NUMBER_CONF_MAX, VALVE_DURATION_MAX_DEFAULT
+ ),
+ PROP_MIN_STEP: self._get_linked_duration_property(
+ INPUT_NUMBER_CONF_STEP, VALVE_DURATION_STEP_DEFAULT
+ ),
+ },
)
if CHAR_REMAINING_DURATION in self.chars:
@@ -319,7 +348,16 @@ class ValveBase(HomeAccessory):
"%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION
)
self.char_remaining_duration = serv_valve.configure_char(
- CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration
+ CHAR_REMAINING_DURATION,
+ getter_callback=self.get_remaining_duration,
+ properties={
+ # Default remaining time maxValue to 48 hours if not set via linked default duration.
+ # pyhap truncates the remaining time to maxValue of the characteristic (pyhap default is 1 hour).
+ # This can potentially show a remaining duration that is lower than the actual remaining duration.
+ PROP_MAX_VALUE: self._get_linked_duration_property(
+ INPUT_NUMBER_CONF_MAX, VALVE_REMAINING_TIME_MAX_DEFAULT
+ ),
+ },
)
# Set the state so it is in sync on initial
@@ -337,12 +375,12 @@ class ValveBase(HomeAccessory):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update switch state after state changed."""
- self._update_duration_chars()
current_state = 1 if new_state.state in self.open_states else 0
_LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state)
self.char_active.set_value(current_state)
_LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state)
self.char_in_use.set_value(current_state)
+ self._update_duration_chars()
def _update_duration_chars(self) -> None:
"""Update valve duration related properties if characteristics are available."""
@@ -387,12 +425,12 @@ class ValveBase(HomeAccessory):
_LOGGER.debug(
"%s: No linked end time entity state available", self.entity_id
)
- return self.get_duration()
+ return self.get_duration() if self.char_in_use.value else 0
end_time = dt_util.parse_datetime(end_time_state)
if end_time is None:
_LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id)
- return self.get_duration()
+ return self.get_duration() if self.char_in_use.value else 0
remaining_time = (end_time - dt_util.utcnow()).total_seconds()
return max(int(remaining_time), 0)
@@ -406,6 +444,20 @@ class ValveBase(HomeAccessory):
return None
return state.state
+ def _get_linked_duration_property(self, attr: str, fallback_value: int) -> int:
+ """Get property from linked duration entity attribute."""
+ if attr not in VALVE_LINKED_DURATION_PROPERTIES:
+ return fallback_value
+ if self.linked_duration_entity is None:
+ return fallback_value
+ state = self.hass.states.get(self.linked_duration_entity)
+ if state is None:
+ return fallback_value
+ attr_value = state.attributes.get(attr, fallback_value)
+ if attr_value is None:
+ return fallback_value
+ return int(attr_value)
+
@TYPES.register("ValveSwitch")
class ValveSwitch(ValveBase):
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
index 931bd40d64c..230e540c9fe 100644
--- a/homeassistant/components/homekit_controller/connection.py
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -20,7 +20,12 @@ from aiohomekit.exceptions import (
EncryptionError,
)
from aiohomekit.model import Accessories, Accessory, Transport
-from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
+from aiohomekit.model.characteristics import (
+ EVENT_CHARACTERISTICS,
+ Characteristic,
+ CharacteristicPermissions,
+ CharacteristicsTypes,
+)
from aiohomekit.model.services import Service, ServicesTypes
from homeassistant.components.thread import async_get_preferred_dataset
@@ -52,7 +57,10 @@ from .utils import IidTuple, unique_id_to_iids
RETRY_INTERVAL = 60 # seconds
MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
-
+# HomeKit accessories have varying limits on how many characteristics
+# they can handle per request. Since we don't know each device's specific limit,
+# we batch requests to a conservative size to avoid overwhelming any device.
+MAX_CHARACTERISTICS_PER_REQUEST = 49
BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds
@@ -179,6 +187,21 @@ class HKDevice:
for aid_iid in characteristics:
self.pollable_characteristics.discard(aid_iid)
+ def get_all_pollable_characteristics(self) -> set[tuple[int, int]]:
+ """Get all characteristics that can be polled.
+
+ This is used during startup to poll all readable characteristics
+ before entities have registered what they care about.
+ """
+ return {
+ (accessory.aid, char.iid)
+ for accessory in self.entity_map.accessories
+ for service in accessory.services
+ for char in service.characteristics
+ if CharacteristicPermissions.paired_read in char.perms
+ and char.type not in EVENT_CHARACTERISTICS
+ }
+
def add_watchable_characteristics(
self, characteristics: list[tuple[int, int]]
) -> None:
@@ -228,7 +251,9 @@ class HKDevice:
_LOGGER.debug(
"Called async_set_available_state with %s for %s", available, self.unique_id
)
- if self.available == available:
+ # Don't mark entities as unavailable during shutdown to preserve their last known state
+ # Also skip if the availability state hasn't changed
+ if (self.hass.is_stopping and not available) or self.available == available:
return
self.available = available
for callback_ in self._availability_callbacks:
@@ -294,7 +319,6 @@ class HKDevice:
await self.pairing.async_populate_accessories_state(
force_update=True, attempts=attempts
)
- self._async_start_polling()
entry.async_on_unload(pairing.dispatcher_connect(self.process_new_events))
entry.async_on_unload(
@@ -305,8 +329,29 @@ class HKDevice:
)
entry.async_on_unload(self._async_cancel_subscription_timer)
+ if transport != Transport.BLE:
+ # Although async_populate_accessories_state fetched the accessory database,
+ # the /accessories endpoint may return cached values from the accessory's
+ # perspective. For example, Ecobee thermostats may report stale temperature
+ # values (like 100°C) in their /accessories response after restarting.
+ # We need to explicitly poll characteristics to get fresh sensor readings
+ # before processing the entity map and creating devices.
+ # Use poll_all=True since entities haven't registered their characteristics yet.
+ try:
+ await self.async_update(poll_all=True)
+ except ValueError as exc:
+ _LOGGER.debug(
+ "Accessory %s responded with unparsable response, first update was skipped: %s",
+ self.unique_id,
+ exc,
+ )
+
await self.async_process_entity_map()
+ if transport != Transport.BLE:
+ # Start regular polling after entity map is processed
+ self._async_start_polling()
+
# If everything is up to date, we can create the entities
# since we know the data is not stale.
await self.async_add_new_entities()
@@ -711,9 +756,11 @@ class HKDevice:
"""Stop interacting with device and prepare for removal from hass."""
await self.pairing.shutdown()
- await self.hass.config_entries.async_unload_platforms(
- self.config_entry, self.platforms
- )
+ # Skip platform unloading during shutdown to preserve entity states
+ if not self.hass.is_stopping:
+ await self.hass.config_entries.async_unload_platforms(
+ self.config_entry, self.platforms
+ )
def process_config_changed(self, config_num: int) -> None:
"""Handle a config change notification from the pairing."""
@@ -854,9 +901,25 @@ class HKDevice:
"""Request an debounced update from the accessory."""
await self._debounced_update.async_call()
- async def async_update(self, now: datetime | None = None) -> None:
- """Poll state of all entities attached to this bridge/accessory."""
- to_poll = self.pollable_characteristics
+ async def async_update(
+ self, now: datetime | None = None, *, poll_all: bool = False
+ ) -> None:
+ """Poll state of all entities attached to this bridge/accessory.
+
+ Args:
+ now: The current time (used by time interval callbacks).
+ poll_all: If True, poll all readable characteristics instead
+ of just the registered ones.
+ This is useful during initial setup before entities have
+ registered their characteristics.
+ """
+ if poll_all:
+ # Poll all readable characteristics during initial startup
+ # excluding device trigger characteristics (buttons, doorbell, etc.)
+ to_poll = self.get_all_pollable_characteristics()
+ else:
+ to_poll = self.pollable_characteristics
+
if not to_poll:
self.async_update_available_state()
_LOGGER.debug(
@@ -889,20 +952,26 @@ class HKDevice:
async with self._polling_lock:
_LOGGER.debug("Starting HomeKit device update: %s", self.unique_id)
- try:
- new_values_dict = await self.get_characteristics(to_poll)
- except AccessoryNotFoundError:
- # Not only did the connection fail, but also the accessory is not
- # visible on the network.
- self.async_set_available_state(False)
- return
- except (AccessoryDisconnectedError, EncryptionError):
- # Temporary connection failure. Device may still available but our
- # connection was dropped or we are reconnecting
- self._poll_failures += 1
- if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
+ new_values_dict: dict[tuple[int, int], dict[str, Any]] = {}
+ to_poll_list = list(to_poll)
+
+ for i in range(0, len(to_poll_list), MAX_CHARACTERISTICS_PER_REQUEST):
+ batch = to_poll_list[i : i + MAX_CHARACTERISTICS_PER_REQUEST]
+ try:
+ batch_values = await self.get_characteristics(batch)
+ new_values_dict.update(batch_values)
+ except AccessoryNotFoundError:
+ # Not only did the connection fail, but also the accessory is not
+ # visible on the network.
self.async_set_available_state(False)
- return
+ return
+ except (AccessoryDisconnectedError, EncryptionError):
+ # Temporary connection failure. Device may still available but our
+ # connection was dropped or we are reconnecting
+ self._poll_failures += 1
+ if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
+ self.async_set_available_state(False)
+ return
self._poll_failures = 0
self.process_new_events(new_values_dict)
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index d15479aa9d5..09cd880a492 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==3.2.15"],
+ "requirements": ["aiohomekit==3.2.20"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py
index ac436ce27a4..9d04576ec28 100644
--- a/homeassistant/components/homekit_controller/utils.py
+++ b/homeassistant/components/homekit_controller/utils.py
@@ -63,7 +63,7 @@ async def async_get_controller(hass: HomeAssistant) -> Controller:
controller = Controller(
async_zeroconf_instance=async_zeroconf_instance,
- bleak_scanner_instance=bleak_scanner_instance, # type: ignore[arg-type]
+ bleak_scanner_instance=bleak_scanner_instance,
char_cache=char_cache,
)
diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json
index 78159189db8..3ce4c1f544d 100644
--- a/homeassistant/components/homematic/strings.json
+++ b/homeassistant/components/homematic/strings.json
@@ -42,7 +42,7 @@
},
"set_device_value": {
"name": "Set device value",
- "description": "Sets a device property on RPC XML interface.",
+ "description": "Controls a device manually. Equivalent to setValue-method from XML-RPC.",
"fields": {
"address": {
"name": "Address",
@@ -80,11 +80,11 @@
"fields": {
"interface": {
"name": "Interface",
- "description": "Select the given interface into install mode."
+ "description": "The interface to set into install mode."
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "1= Normal mode / 2= Remove exists old links."
+ "description": "1= Normal mode / 2= Remove existing old links."
},
"time": {
"name": "Time",
@@ -98,11 +98,11 @@
},
"put_paramset": {
"name": "Put paramset",
- "description": "Calls to putParamset in the RPC XML interface.",
+ "description": "Manually changes a device’s paramset. Equivalent to putParamset-method from XML-RPC.",
"fields": {
"interface": {
"name": "Interface",
- "description": "The interfaces name from the config."
+ "description": "The interface's name from the config."
},
"address": {
"name": "Address",
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index 14b5ac39310..1470d1147ab 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
- "requirements": ["homematicip==2.2.0"]
+ "requirements": ["homematicip==2.3.0"]
}
diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py
index 1cfb3a55552..9e663ae5490 100644
--- a/homeassistant/components/homematicip_cloud/services.py
+++ b/homeassistant/components/homematicip_cloud/services.py
@@ -124,7 +124,7 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema(
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the HomematicIP Cloud services."""
- @verify_domain_control(hass, DOMAIN)
+ @verify_domain_control(DOMAIN)
async def async_call_hmipc_service(service: ServiceCall) -> None:
"""Call correct HomematicIP Cloud service."""
service_name = service.service
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index f9924a68db4..0d06c96cff1 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -12,6 +12,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==9.2.0"],
+ "requirements": ["python-homewizard-energy==9.3.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py
index a703043a63b..a4c5c5c64a0 100644
--- a/homeassistant/components/homewizard/number.py
+++ b/homeassistant/components/homewizard/number.py
@@ -20,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
- if entry.runtime_data.data.device.supports_state():
+ if entry.runtime_data.data.device.supports_led_brightness():
async_add_entities([HWEnergyNumberEntity(entry.runtime_data)])
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index dd557532240..a35f841175e 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -36,6 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
+from homeassistant.util.variance import ignore_variance
from .const import DOMAIN
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
@@ -66,15 +67,13 @@ def to_percentage(value: float | None) -> float | None:
return value * 100 if value is not None else None
-def time_to_datetime(value: int | None) -> datetime | None:
- """Convert seconds to datetime when value is not None."""
- return (
- utcnow().replace(microsecond=0) - timedelta(seconds=value)
- if value is not None
- else None
- )
+def uptime_to_datetime(value: int) -> datetime:
+ """Convert seconds to datetime timestamp."""
+ return utcnow().replace(microsecond=0) - timedelta(seconds=value)
+uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
+
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
@@ -647,7 +646,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
lambda data: data.system is not None and data.system.uptime_s is not None
),
value_fn=(
- lambda data: time_to_datetime(data.system.uptime_s) if data.system else None
+ lambda data: (
+ uptime_to_stable_datetime(data.system.uptime_s)
+ if data.system is not None and data.system.uptime_s is not None
+ else None
+ )
),
),
)
diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json
index 67295ec5802..d19e33d709e 100644
--- a/homeassistant/components/honeywell/strings.json
+++ b/homeassistant/components/honeywell/strings.json
@@ -88,7 +88,7 @@
"message": "Honeywell set temperature failed: invalid temperature {temperature}"
},
"temp_failed_range": {
- "message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {heat}, Cool Temperature: {cool}"
+ "message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat temperature: {heat}, Cool temperature: {cool}"
},
"set_hold_failed": {
"message": "Honeywell could not set permanent hold"
diff --git a/homeassistant/components/huawei_lte/quality_scale.yaml b/homeassistant/components/huawei_lte/quality_scale.yaml
new file mode 100644
index 00000000000..05cfb812da1
--- /dev/null
+++ b/homeassistant/components/huawei_lte/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: done
+ comment: When we refactor to use a coordinator, be sure to place it in coordinator.py.
+ config-flow-test-coverage:
+ status: todo
+ comment: Use mock calls to check test_urlize_plain_host instead of user_input mod checks, combine test_show_set_form with a happy path flow, finish test_connection_errors and test_login_error with CREATE_ENTRY to check error recovery, move test_success to top and assert unique id in it, split test_reauth to two so we can test incorrect password recovery.
+ config-flow:
+ status: todo
+ comment: See if we can catch more specific exceptions in get_device_info.
+ dependency-transparency:
+ status: todo
+ comment: huawei-lte-api and stringcase are not built and published to PyPI from a public CI pipeline.
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: todo
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: todo
+ comment: Kind of done, but to be reviewed, there's probably room for improvement. Maybe address this when converting to use a data update coordinator. See also https://github.com/home-assistant/core/issues/55495
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests, rename test_switch.py to test_switch.py + make its functions receive hass as first parameter where applicable.
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions:
+ status: todo
+ comment: Some info exists, but there's room for improvement.
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations:
+ status: todo
+ comment: Buttons and selects are lacking translations.
+ exception-translations: todo
+ icon-translations:
+ status: done
+ comment: Some use numeric state ranges or the like that are not available with icons.json state selectors.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: todo
+ comment: Not sure if we have anything applicable.
+ stale-devices:
+ status: todo
+ comment: Not sure of applicability.
+
+ # Platinum
+ async-dependency:
+ status: todo
+ comment: The integration is async, but underlying huawei-lte-api is not.
+ inject-websession:
+ status: exempt
+ comment: Underlying huawei-lte-api does not use aiohttp or httpx, so this does not apply.
+ strict-typing:
+ status: todo
+ comment: Integration is strictly typechecked already, and huawei-lte-api and url-normalize are in order. stringcase is not typed.
diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py
index bec44352613..3328b5ab659 100644
--- a/homeassistant/components/hue/config_flow.py
+++ b/homeassistant/components/hue/config_flow.py
@@ -9,7 +9,9 @@ from typing import Any
import aiohttp
from aiohue import LinkButtonNotPressed, create_app_key
from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp
+from aiohue.errors import AiohueException
from aiohue.util import normalize_bridge_id
+from aiohue.v2 import HueBridgeV2
import slugify as unicode_slug
import voluptuous as vol
@@ -40,6 +42,9 @@ HUE_MANUFACTURERURL = ("http://www.philips.com", "http://www.philips-hue.com")
HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"]
HUE_MANUAL_BRIDGE_ID = "manual"
+BSB002_MODEL_ID = "BSB002"
+BSB003_MODEL_ID = "BSB003"
+
class HueFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Hue config flow."""
@@ -74,7 +79,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
"""Return a DiscoveredHueBridge object."""
try:
bridge = await discover_bridge(
- host, websession=aiohttp_client.async_get_clientsession(self.hass)
+ host,
+ websession=aiohttp_client.async_get_clientsession(
+ # NOTE: we disable SSL verification for now due to the fact that the (BSB003)
+ # Hue bridge uses a certificate from a on-bridge root authority.
+ # We need to specifically handle this case in a follow-up update.
+ self.hass,
+ verify_ssl=False,
+ ),
)
except aiohttp.ClientError as err:
LOGGER.warning(
@@ -110,7 +122,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with asyncio.timeout(5):
bridges = await discover_nupnp(
- websession=aiohttp_client.async_get_clientsession(self.hass)
+ websession=aiohttp_client.async_get_clientsession(
+ self.hass, verify_ssl=False
+ )
)
except TimeoutError:
bridges = []
@@ -178,7 +192,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
app_key = await create_app_key(
bridge.host,
f"home-assistant#{device_name}",
- websession=aiohttp_client.async_get_clientsession(self.hass),
+ websession=aiohttp_client.async_get_clientsession(
+ self.hass, verify_ssl=False
+ ),
)
except LinkButtonNotPressed:
errors["base"] = "register_failed"
@@ -228,7 +244,6 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info.host}, reload_on_update=True
)
-
# we need to query the other capabilities too
bridge = await self._get_bridge(
discovery_info.host, discovery_info.properties["bridgeid"]
@@ -236,6 +251,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
if bridge is None:
return self.async_abort(reason="cannot_connect")
self.bridge = bridge
+ if (
+ bridge.supports_v2
+ and discovery_info.properties.get("modelid") == BSB003_MODEL_ID
+ ):
+ # try to handle migration of BSB002 --> BSB003
+ if await self._check_migrated_bridge(bridge):
+ return self.async_abort(reason="migrated_bridge")
+
return await self.async_step_link()
async def async_step_homekit(
@@ -272,6 +295,55 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
self.bridge = bridge
return await self.async_step_link()
+ async def _check_migrated_bridge(self, bridge: DiscoveredHueBridge) -> bool:
+ """Check if the discovered bridge is a migrated bridge."""
+ # Try to handle migration of BSB002 --> BSB003.
+ # Once we detect a BSB003 bridge on the network which has not yet been
+ # configured in HA (otherwise we would have had a unique id match),
+ # we check if we have any existing (BSB002) entries and if we can connect to the
+ # new bridge with our previously stored api key.
+ # If that succeeds, we migrate the entry to the new bridge.
+ for conf_entry in self.hass.config_entries.async_entries(
+ DOMAIN, include_ignore=False, include_disabled=False
+ ):
+ if conf_entry.data[CONF_API_VERSION] != 2:
+ continue
+ if conf_entry.data[CONF_HOST] == bridge.host:
+ continue
+ # found an existing (BSB002) bridge entry,
+ # check if we can connect to the new BSB003 bridge using the old credentials
+ api = HueBridgeV2(bridge.host, conf_entry.data[CONF_API_KEY])
+ try:
+ await api.fetch_full_state()
+ except (AiohueException, aiohttp.ClientError):
+ continue
+ old_bridge_id = conf_entry.unique_id
+ assert old_bridge_id is not None
+ # found a matching entry, migrate it
+ self.hass.config_entries.async_update_entry(
+ conf_entry,
+ data={
+ **conf_entry.data,
+ CONF_HOST: bridge.host,
+ },
+ unique_id=bridge.id,
+ )
+ # also update the bridge device
+ dev_reg = dr.async_get(self.hass)
+ if bridge_device := dev_reg.async_get_device(
+ identifiers={(DOMAIN, old_bridge_id)}
+ ):
+ dev_reg.async_update_device(
+ bridge_device.id,
+ # overwrite identifiers with new bridge id
+ new_identifiers={(DOMAIN, bridge.id)},
+ # overwrite mac addresses with empty set to drop the old (incorrect) addresses
+ # this will be auto corrected once the integration is loaded
+ new_connections=set(),
+ )
+ return True
+ return False
+
class HueV1OptionsFlowHandler(OptionsFlow):
"""Handle Hue options for V1 implementation."""
diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py
index 4cffbb73a38..c13cccd48e6 100644
--- a/homeassistant/components/hue/event.py
+++ b/homeassistant/components/hue/event.py
@@ -6,6 +6,7 @@ from typing import Any
from aiohue.v2 import HueBridgeV2
from aiohue.v2.controllers.events import EventType
+from aiohue.v2.models.bell_button import BellButton
from aiohue.v2.models.button import Button
from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection
@@ -39,19 +40,27 @@ async def async_setup_entry(
@callback
def async_add_entity(
event_type: EventType,
- resource: Button | RelativeRotary,
+ resource: Button | RelativeRotary | BellButton,
) -> None:
"""Add entity from Hue resource."""
if isinstance(resource, RelativeRotary):
async_add_entities(
[HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)]
)
+ elif isinstance(resource, BellButton):
+ async_add_entities(
+ [HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)]
+ )
else:
async_add_entities(
[HueButtonEventEntity(bridge, api.sensors.button, resource)]
)
- for controller in (api.sensors.button, api.sensors.relative_rotary):
+ for controller in (
+ api.sensors.button,
+ api.sensors.relative_rotary,
+ api.sensors.bell_button,
+ ):
# add all current items in controller
for item in controller:
async_add_entity(EventType.RESOURCE_ADDED, item)
@@ -67,6 +76,8 @@ async def async_setup_entry(
class HueButtonEventEntity(HueBaseEntity, EventEntity):
"""Representation of a Hue Event entity from a button resource."""
+ resource: Button | BellButton
+
entity_description = EventEntityDescription(
key="button",
device_class=EventDeviceClass.BUTTON,
@@ -91,7 +102,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
}
@callback
- def _handle_event(self, event_type: EventType, resource: Button) -> None:
+ def _handle_event(
+ self, event_type: EventType, resource: Button | BellButton
+ ) -> None:
"""Handle status event for this resource (or it's parent)."""
if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id:
if resource.button is None or resource.button.button_report is None:
@@ -102,6 +115,18 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
super()._handle_event(event_type, resource)
+class HueBellButtonEventEntity(HueButtonEventEntity):
+ """Representation of a Hue Event entity from a bell_button resource."""
+
+ resource: Button | BellButton
+
+ entity_description = EventEntityDescription(
+ key="bell_button",
+ device_class=EventDeviceClass.DOORBELL,
+ has_entity_name=True,
+ )
+
+
class HueRotaryEventEntity(HueBaseEntity, EventEntity):
"""Representation of a Hue Event entity from a RelativeRotary resource."""
diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json
index 8bc3d84bd50..0adc0dfc3b3 100644
--- a/homeassistant/components/hue/manifest.json
+++ b/homeassistant/components/hue/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "hue",
"name": "Philips Hue",
- "codeowners": ["@balloob", "@marcelveldt"],
+ "codeowners": ["@marcelveldt"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hue",
"homekit": {
@@ -10,6 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
- "requirements": ["aiohue==4.7.4"],
+ "requirements": ["aiohue==4.8.0"],
"zeroconf": ["_hue._tcp.local."]
}
diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py
index 0fd6e8bdae0..1a70e98e5b3 100644
--- a/homeassistant/components/hue/services.py
+++ b/homeassistant/components/hue/services.py
@@ -64,7 +64,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_HUE_ACTIVATE_SCENE,
- verify_domain_control(hass, DOMAIN)(hue_activate_scene),
+ verify_domain_control(DOMAIN)(hue_activate_scene),
schema=vol.Schema(
{
vol.Required(ATTR_GROUP_NAME): cv.string,
diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json
index 44a6eb72acc..b70d4feb526 100644
--- a/homeassistant/components/hue/strings.json
+++ b/homeassistant/components/hue/strings.json
@@ -21,7 +21,7 @@
},
"link": {
"title": "Link Hub",
- "description": "Press the button on the bridge to register Philips Hue with Home Assistant.\n\n"
+ "description": "Press the button on the bridge to register Philips Hue with Home Assistant."
}
},
"error": {
diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py
index 17584a0f5cb..7b7717cbf76 100644
--- a/homeassistant/components/hue/v2/binary_sensor.py
+++ b/homeassistant/components/hue/v2/binary_sensor.py
@@ -13,13 +13,18 @@ from aiohue.v2.controllers.events import EventType
from aiohue.v2.controllers.sensors import (
CameraMotionController,
ContactController,
+ GroupedMotionController,
MotionController,
+ SecurityAreaMotionController,
TamperController,
)
from aiohue.v2.models.camera_motion import CameraMotion
from aiohue.v2.models.contact import Contact, ContactState
from aiohue.v2.models.entertainment_configuration import EntertainmentStatus
+from aiohue.v2.models.grouped_motion import GroupedMotion
from aiohue.v2.models.motion import Motion
+from aiohue.v2.models.resource import ResourceTypes
+from aiohue.v2.models.security_area_motion import SecurityAreaMotion
from aiohue.v2.models.tamper import Tamper, TamperState
from homeassistant.components.binary_sensor import (
@@ -29,21 +34,54 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from ..bridge import HueConfigEntry
+from ..bridge import HueBridge, HueConfigEntry
+from ..const import DOMAIN
from .entity import HueBaseEntity
-type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper
+type SensorType = (
+ CameraMotion
+ | Contact
+ | Motion
+ | EntertainmentConfiguration
+ | Tamper
+ | GroupedMotion
+ | SecurityAreaMotion
+)
type ControllerType = (
CameraMotionController
| ContactController
| MotionController
| EntertainmentConfigurationController
| TamperController
+ | GroupedMotionController
+ | SecurityAreaMotionController
)
+def _resource_valid(resource: SensorType, controller: ControllerType) -> bool:
+ """Return True if the resource is valid."""
+ if isinstance(resource, GroupedMotion):
+ # filter out GroupedMotion sensors that are not linked to a valid group/parent
+ if resource.owner.rtype not in (
+ ResourceTypes.ROOM,
+ ResourceTypes.ZONE,
+ ResourceTypes.SERVICE_GROUP,
+ ):
+ return False
+ # guard against GroupedMotion without parent (should not happen, but just in case)
+ if not (parent := controller.get_parent(resource.id)):
+ return False
+ # filter out GroupedMotion sensors that have only one member, because Hue creates one
+ # default grouped Motion sensor per zone/room, which is not useful to expose in HA
+ if len(parent.children) <= 1:
+ return False
+ # default/other checks can go here (none for now)
+ return True
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HueConfigEntry,
@@ -59,11 +97,17 @@ async def async_setup_entry(
@callback
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
- """Add Hue Binary Sensor."""
+ """Add Hue Binary Sensor from resource added callback."""
+ if not _resource_valid(resource, controller):
+ return
async_add_entities([make_binary_sensor_entity(resource)])
# add all current items in controller
- async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller)
+ async_add_entities(
+ make_binary_sensor_entity(sensor)
+ for sensor in controller
+ if _resource_valid(sensor, controller)
+ )
# register listener for new sensors
config_entry.async_on_unload(
@@ -78,6 +122,8 @@ async def async_setup_entry(
register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor)
register_items(api.sensors.contact, HueContactSensor)
register_items(api.sensors.tamper, HueTamperSensor)
+ register_items(api.sensors.grouped_motion, HueGroupedMotionSensor)
+ register_items(api.sensors.security_area_motion, HueMotionAwareSensor)
# pylint: disable-next=hass-enforce-class-module
@@ -99,7 +145,88 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity):
if not self.resource.enabled:
# Force None (unknown) if the sensor is set to disabled in Hue
return None
- return self.resource.motion.value
+ if not (motion_feature := self.resource.motion):
+ return None
+ if motion_feature.motion_report is not None:
+ return motion_feature.motion_report.motion
+ return motion_feature.motion
+
+
+# pylint: disable-next=hass-enforce-class-module
+class HueGroupedMotionSensor(HueMotionSensor):
+ """Representation of a Hue Grouped Motion sensor."""
+
+ controller: GroupedMotionController
+ resource: GroupedMotion
+
+ def __init__(
+ self,
+ bridge: HueBridge,
+ controller: GroupedMotionController,
+ resource: GroupedMotion,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(bridge, controller, resource)
+ # link the GroupedMotion sensor to the parent the sensor is associated with
+ # which can either be a special ServiceGroup or a Zone/Room
+ parent = self.controller.get_parent(resource.id)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, parent.id)},
+ )
+
+
+# pylint: disable-next=hass-enforce-class-module
+class HueMotionAwareSensor(HueMotionSensor):
+ """Representation of a Motion sensor based on Hue Motion Aware.
+
+ Note that we only create sensors for the SecurityAreaMotion resource
+ and not for the ConvenienceAreaMotion resource, because the latter
+ does not have a state when it's not directly controlling lights.
+ The SecurityAreaMotion resource is always available with a state, allowing
+ Home Assistant users to actually use it as a motion sensor in their HA automations.
+ """
+
+ controller: SecurityAreaMotionController
+ resource: SecurityAreaMotion
+
+ entity_description = BinarySensorEntityDescription(
+ key="motion_sensor",
+ device_class=BinarySensorDeviceClass.MOTION,
+ has_entity_name=False,
+ )
+
+ @property
+ def name(self) -> str:
+ """Return sensor name."""
+ return self.controller.get_motion_area_configuration(self.resource.id).name
+
+ def __init__(
+ self,
+ bridge: HueBridge,
+ controller: SecurityAreaMotionController,
+ resource: SecurityAreaMotion,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(bridge, controller, resource)
+ # link the MotionAware sensor to the group the sensor is associated with
+ self._motion_area_configuration = self.controller.get_motion_area_configuration(
+ resource.id
+ )
+ group_id = self._motion_area_configuration.group.rid
+ self.group = self.bridge.api.groups[group_id]
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self.group.id)},
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added."""
+ await super().async_added_to_hass()
+ # subscribe to updates of the MotionAreaConfiguration to update the name
+ self.async_on_remove(
+ self.bridge.api.config.subscribe(
+ self._handle_event, self._motion_area_configuration.id
+ )
+ )
# pylint: disable-next=hass-enforce-class-module
diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py
index 8979befcf73..e6bded7a7f7 100644
--- a/homeassistant/components/hue/v2/device.py
+++ b/homeassistant/components/hue/v2/device.py
@@ -7,8 +7,9 @@ from typing import TYPE_CHECKING
from aiohue.v2 import HueBridgeV2
from aiohue.v2.controllers.events import EventType
from aiohue.v2.controllers.groups import Room, Zone
-from aiohue.v2.models.device import Device, DeviceArchetypes
+from aiohue.v2.models.device import Device
from aiohue.v2.models.resource import ResourceTypes
+from aiohue.v2.models.service_group import ServiceGroup
from homeassistant.const import (
ATTR_CONNECTIONS,
@@ -39,16 +40,16 @@ async def async_setup_devices(bridge: HueBridge):
dev_controller = api.devices
@callback
- def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry:
+ def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry:
"""Register a Hue device in device registry."""
- if isinstance(hue_resource, (Room, Zone)):
+ if isinstance(hue_resource, (Room, Zone, ServiceGroup)):
# Register a Hue Room/Zone as service in HA device registry.
return dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN, hue_resource.id)},
name=hue_resource.metadata.name,
- model=hue_resource.type.value.title(),
+ model=hue_resource.type.value.replace("_", " ").title(),
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
via_device=(DOMAIN, api.config.bridge_device.id),
suggested_area=hue_resource.metadata.name
@@ -66,7 +67,7 @@ async def async_setup_devices(bridge: HueBridge):
}
if room := dev_controller.get_room(hue_resource.id):
params[ATTR_SUGGESTED_AREA] = room.metadata.name
- if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2:
+ if hue_resource.id == api.config.bridge_device.id:
params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id))
else:
params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id)
@@ -85,7 +86,7 @@ async def async_setup_devices(bridge: HueBridge):
@callback
def handle_device_event(
- evt_type: EventType, hue_resource: Device | Room | Zone
+ evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup
) -> None:
"""Handle event from Hue controller."""
if evt_type == EventType.RESOURCE_DELETED:
@@ -97,12 +98,11 @@ async def async_setup_devices(bridge: HueBridge):
# create/update all current devices found in controllers
# sort the devices to ensure bridges are added first
hue_devices = list(dev_controller)
- hue_devices.sort(
- key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
- )
+ hue_devices.sort(key=lambda dev: dev.id != api.config.bridge_device.id)
known_devices = [add_device(hue_device) for hue_device in hue_devices]
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
+ known_devices += [add_device(sg) for sg in api.config.service_group]
# Check for nodes that no longer exist and remove them
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
@@ -113,3 +113,4 @@ async def async_setup_devices(bridge: HueBridge):
entry.async_on_unload(dev_controller.subscribe(handle_device_event))
entry.async_on_unload(api.groups.room.subscribe(handle_device_event))
entry.async_on_unload(api.groups.zone.subscribe(handle_device_event))
+ entry.async_on_unload(api.config.service_group.subscribe(handle_device_event))
diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py
index 4db9bc16ca8..c9d7bf6408b 100644
--- a/homeassistant/components/hue/v2/group.py
+++ b/homeassistant/components/hue/v2/group.py
@@ -9,6 +9,7 @@ from aiohue.v2 import HueBridgeV2
from aiohue.v2.controllers.events import EventType
from aiohue.v2.controllers.groups import GroupedLight, Room, Zone
from aiohue.v2.models.feature import DynamicStatus
+from aiohue.v2.models.resource import ResourceTypes
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -66,7 +67,11 @@ async def async_setup_entry(
# add current items
for item in api.groups.grouped_light.items:
- await async_add_light(EventType.RESOURCE_ADDED, item)
+ if item.owner.rtype not in [
+ ResourceTypes.BRIDGE_HOME,
+ ResourceTypes.PRIVATE_GROUP,
+ ]:
+ await async_add_light(EventType.RESOURCE_ADDED, item)
# register listener for new grouped_light
config_entry.async_on_unload(
@@ -157,7 +162,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
"""Turn the grouped_light on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
xy_color = kwargs.get(ATTR_XY_COLOR)
- color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
+ color_temp = normalize_hue_colortemp(
+ kwargs.get(ATTR_COLOR_TEMP_KELVIN),
+ color_util.color_temperature_kelvin_to_mired(self.max_color_temp_kelvin),
+ color_util.color_temperature_kelvin_to_mired(self.min_color_temp_kelvin),
+ )
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
flash = kwargs.get(ATTR_FLASH)
@@ -226,15 +235,26 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
lights_with_color_support = 0
lights_with_color_temp_support = 0
lights_with_dimming_support = 0
+ lights_on_with_dimming_support = 0
total_brightness = 0
all_lights = self.controller.get_lights(self.resource.id)
lights_in_colortemp_mode = 0
+ lights_in_xy_mode = 0
lights_in_dynamic_mode = 0
+ # accumulate color values
+ xy_total_x = 0.0
+ xy_total_y = 0.0
+ xy_count = 0
+ temp_total = 0.0
+
# loop through all lights to find capabilities
for light in all_lights:
+ # reset per-light colortemp on flag
+ light_in_colortemp_mode = False
+ # check if light has color temperature
if color_temp := light.color_temperature:
lights_with_color_temp_support += 1
- # we assume mired values from the first capable light
+ # default to mired values from the last capable light
self._attr_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(color_temp.mirek)
if color_temp.mirek
@@ -250,15 +270,39 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
color_temp.mirek_schema.mirek_minimum
)
)
- if color_temp.mirek is not None and color_temp.mirek_valid:
+ # counters for color mode vote and average temp
+ if (
+ light.on.on
+ and color_temp.mirek is not None
+ and color_temp.mirek_valid
+ ):
lights_in_colortemp_mode += 1
+ light_in_colortemp_mode = True
+ temp_total += color_util.color_temperature_mired_to_kelvin(
+ color_temp.mirek
+ )
+ # check if light has color xy
if color := light.color:
lights_with_color_support += 1
- # we assume xy values from the first capable light
+ # default to xy values from the last capable light
self._attr_xy_color = (color.xy.x, color.xy.y)
+ # counters for color mode vote and average xy color
+ if light.on.on:
+ xy_total_x += color.xy.x
+ xy_total_y += color.xy.y
+ xy_count += 1
+ # only count for colour mode vote if
+ # this light is not in colortemp mode
+ if not light_in_colortemp_mode:
+ lights_in_xy_mode += 1
+ # check if light has dimming
if dimming := light.dimming:
lights_with_dimming_support += 1
- total_brightness += dimming.brightness
+ # accumulate brightness values
+ if light.on.on:
+ total_brightness += dimming.brightness
+ lights_on_with_dimming_support += 1
+ # check if light is in dynamic mode
if (
light.dynamics
and light.dynamics.status == DynamicStatus.DYNAMIC_PALETTE
@@ -266,10 +310,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
lights_in_dynamic_mode += 1
# this is a bit hacky because light groups may contain lights
- # of different capabilities. We set a colormode as supported
- # if any of the lights support it
+ # of different capabilities
# this means that the state is derived from only some of the lights
# and will never be 100% accurate but it will be close
+
+ # assign group color support modes based on light capabilities
if lights_with_color_support > 0:
supported_color_modes.add(ColorMode.XY)
if lights_with_color_temp_support > 0:
@@ -278,19 +323,38 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
if len(supported_color_modes) == 0:
# only add color mode brightness if no color variants
supported_color_modes.add(ColorMode.BRIGHTNESS)
- self._brightness_pct = total_brightness / lights_with_dimming_support
- self._attr_brightness = round(
- ((total_brightness / lights_with_dimming_support) / 100) * 255
- )
+ # as we have brightness support, set group brightness values
+ if lights_on_with_dimming_support > 0:
+ self._brightness_pct = total_brightness / lights_on_with_dimming_support
+ self._attr_brightness = round(
+ ((total_brightness / lights_on_with_dimming_support) / 100) * 255
+ )
else:
supported_color_modes.add(ColorMode.ONOFF)
self._dynamic_mode_active = lights_in_dynamic_mode > 0
self._attr_supported_color_modes = supported_color_modes
- # pick a winner for the current colormode
- if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0:
+ # set the group color values if there are any color lights on
+ if xy_count > 0:
+ self._attr_xy_color = (
+ round(xy_total_x / xy_count, 5),
+ round(xy_total_y / xy_count, 5),
+ )
+ if lights_in_colortemp_mode > 0:
+ avg_temp = temp_total / lights_in_colortemp_mode
+ self._attr_color_temp_kelvin = round(avg_temp)
+ # pick a winner for the current color mode based on the majority of on lights
+ # if there is no winner pick the highest mode from group capabilities
+ if lights_in_xy_mode > 0 and lights_in_xy_mode >= lights_in_colortemp_mode:
+ self._attr_color_mode = ColorMode.XY
+ elif (
+ lights_in_colortemp_mode > 0
+ and lights_in_colortemp_mode > lights_in_xy_mode
+ ):
self._attr_color_mode = ColorMode.COLOR_TEMP
elif lights_with_color_support > 0:
self._attr_color_mode = ColorMode.XY
+ elif lights_with_color_temp_support > 0:
+ self._attr_color_mode = ColorMode.COLOR_TEMP
elif lights_with_dimming_support > 0:
self._attr_color_mode = ColorMode.BRIGHTNESS
else:
diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py
index 384d2a30596..12c0d6d10e8 100644
--- a/homeassistant/components/hue/v2/helpers.py
+++ b/homeassistant/components/hue/v2/helpers.py
@@ -23,11 +23,12 @@ def normalize_hue_transition(transition: float | None) -> float | None:
return transition
-def normalize_hue_colortemp(colortemp_k: int | None) -> int | None:
+def normalize_hue_colortemp(
+ colortemp_k: int | None, min_mireds: int, max_mireds: int
+) -> int | None:
"""Return color temperature within Hue's ranges."""
if colortemp_k is None:
return None
- colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k)
- # Hue only accepts a range between 153..500
- colortemp = min(colortemp, 500)
- return max(colortemp, 153)
+ colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k)
+ # Hue only accepts a range between min_mireds..max_mireds
+ return min(max(colortemp_mireds, min_mireds), max_mireds)
diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py
index d83cdaa8009..e22d2c09f43 100644
--- a/homeassistant/components/hue/v2/light.py
+++ b/homeassistant/components/hue/v2/light.py
@@ -40,8 +40,8 @@ from .helpers import (
normalize_hue_transition,
)
-FALLBACK_MIN_KELVIN = 6500
-FALLBACK_MAX_KELVIN = 2000
+FALLBACK_MIN_MIREDS = 153 # hue default for most lights
+FALLBACK_MAX_MIREDS = 500 # hue default for most lights
FALLBACK_KELVIN = 5800 # halfway
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
@@ -177,25 +177,31 @@ class HueLight(HueBaseEntity, LightEntity):
# return a fallback value to prevent issues with mired->kelvin conversions
return FALLBACK_KELVIN
+ @property
+ def max_color_temp_mireds(self) -> int:
+ """Return the warmest color_temp in mireds (so highest number) that this light supports."""
+ if color_temp := self.resource.color_temperature:
+ return color_temp.mirek_schema.mirek_maximum
+ # return a fallback value if the light doesn't provide limits
+ return FALLBACK_MAX_MIREDS
+
+ @property
+ def min_color_temp_mireds(self) -> int:
+ """Return the coldest color_temp in mireds (so lowest number) that this light supports."""
+ if color_temp := self.resource.color_temperature:
+ return color_temp.mirek_schema.mirek_minimum
+ # return a fallback value if the light doesn't provide limits
+ return FALLBACK_MIN_MIREDS
+
@property
def max_color_temp_kelvin(self) -> int:
"""Return the coldest color_temp_kelvin that this light supports."""
- if color_temp := self.resource.color_temperature:
- return color_util.color_temperature_mired_to_kelvin(
- color_temp.mirek_schema.mirek_minimum
- )
- # return a fallback value to prevent issues with mired->kelvin conversions
- return FALLBACK_MAX_KELVIN
+ return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds)
@property
def min_color_temp_kelvin(self) -> int:
"""Return the warmest color_temp_kelvin that this light supports."""
- if color_temp := self.resource.color_temperature:
- return color_util.color_temperature_mired_to_kelvin(
- color_temp.mirek_schema.mirek_maximum
- )
- # return a fallback value to prevent issues with mired->kelvin conversions
- return FALLBACK_MIN_KELVIN
+ return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds)
@property
def extra_state_attributes(self) -> dict[str, str] | None:
@@ -220,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity):
"""Turn the device on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
xy_color = kwargs.get(ATTR_XY_COLOR)
- color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
+ color_temp = normalize_hue_colortemp(
+ kwargs.get(ATTR_COLOR_TEMP_KELVIN),
+ self.min_color_temp_mireds,
+ self.max_color_temp_mireds,
+ )
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
if self._last_brightness and brightness is None:
# The Hue bridge sets the brightness to 1% when turning on a bulb
diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py
index 1eec4eaa6b9..0c92b0c8b3e 100644
--- a/homeassistant/components/hue/v2/sensor.py
+++ b/homeassistant/components/hue/v2/sensor.py
@@ -9,13 +9,16 @@ from aiohue.v2 import HueBridgeV2
from aiohue.v2.controllers.events import EventType
from aiohue.v2.controllers.sensors import (
DevicePowerController,
+ GroupedLightLevelController,
LightLevelController,
SensorsController,
TemperatureController,
ZigbeeConnectivityController,
)
from aiohue.v2.models.device_power import DevicePower
+from aiohue.v2.models.grouped_light_level import GroupedLightLevel
from aiohue.v2.models.light_level import LightLevel
+from aiohue.v2.models.resource import ResourceTypes
from aiohue.v2.models.temperature import Temperature
from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity
@@ -27,20 +30,50 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from ..bridge import HueBridge, HueConfigEntry
+from ..const import DOMAIN
from .entity import HueBaseEntity
-type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity
+type SensorType = (
+ DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel
+)
type ControllerType = (
DevicePowerController
| LightLevelController
| TemperatureController
| ZigbeeConnectivityController
+ | GroupedLightLevelController
)
+def _resource_valid(
+ resource: SensorType, controller: ControllerType, api: HueBridgeV2
+) -> bool:
+ """Return True if the resource is valid."""
+ if isinstance(resource, GroupedLightLevel):
+ # filter out GroupedLightLevel sensors that are not linked to a valid group/parent
+ if resource.owner.rtype not in (
+ ResourceTypes.ROOM,
+ ResourceTypes.ZONE,
+ ResourceTypes.SERVICE_GROUP,
+ ):
+ return False
+ # guard against GroupedLightLevel without parent (should not happen, but just in case)
+ parent_id = resource.owner.rid
+ parent = api.groups.get(parent_id) or api.config.get(parent_id)
+ if not parent:
+ return False
+ # filter out GroupedLightLevel sensors that have only one member, because Hue creates one
+ # default grouped LightLevel sensor per zone/room, which is not useful to expose in HA
+ if len(parent.children) <= 1:
+ return False
+ # default/other checks can go here (none for now)
+ return True
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HueConfigEntry,
@@ -58,10 +91,16 @@ async def async_setup_entry(
@callback
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
"""Add Hue Sensor."""
+ if not _resource_valid(resource, controller, api):
+ return
async_add_entities([make_sensor_entity(resource)])
# add all current items in controller
- async_add_entities(make_sensor_entity(sensor) for sensor in controller)
+ async_add_entities(
+ make_sensor_entity(sensor)
+ for sensor in controller
+ if _resource_valid(sensor, controller, api)
+ )
# register listener for new sensors
config_entry.async_on_unload(
@@ -75,6 +114,7 @@ async def async_setup_entry(
register_items(ctrl_base.light_level, HueLightLevelSensor)
register_items(ctrl_base.device_power, HueBatterySensor)
register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor)
+ register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor)
# pylint: disable-next=hass-enforce-class-module
@@ -140,6 +180,31 @@ class HueLightLevelSensor(HueSensorBase):
}
+# pylint: disable-next=hass-enforce-class-module
+class HueGroupedLightLevelSensor(HueLightLevelSensor):
+ """Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource."""
+
+ controller: GroupedLightLevelController
+ resource: GroupedLightLevel
+
+ def __init__(
+ self,
+ bridge: HueBridge,
+ controller: GroupedLightLevelController,
+ resource: GroupedLightLevel,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(bridge, controller, resource)
+ # link the GroupedLightLevel sensor to the parent the sensor is associated with
+ # which can either be a special ServiceGroup or a Zone/Room
+ api = self.bridge.api
+ parent_id = resource.owner.rid
+ parent = api.groups.get(parent_id) or api.config.get(parent_id)
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, parent.id)},
+ )
+
+
# pylint: disable-next=hass-enforce-class-module
class HueBatterySensor(HueSensorBase):
"""Representation of a Hue Battery sensor."""
diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json
index a80708d9a3f..22ceae3fa7d 100644
--- a/homeassistant/components/hunterdouglas_powerview/manifest.json
+++ b/homeassistant/components/hunterdouglas_powerview/manifest.json
@@ -18,6 +18,6 @@
},
"iot_class": "local_polling",
"loggers": ["aiopvapi"],
- "requirements": ["aiopvapi==3.1.1"],
+ "requirements": ["aiopvapi==3.2.1"],
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
}
diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py
index dc35c47ff4a..3c50f78141b 100644
--- a/homeassistant/components/husqvarna_automower/coordinator.py
+++ b/homeassistant/components/husqvarna_automower/coordinator.py
@@ -30,9 +30,8 @@ _LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
-PONG_TIMEOUT = timedelta(seconds=90)
-PING_INTERVAL = timedelta(seconds=10)
-PING_TIMEOUT = timedelta(seconds=5)
+PING_INTERVAL = 60
+
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
@@ -56,13 +55,13 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
update_interval=SCAN_INTERVAL,
)
self.api = api
- self.ws_connected: bool = False
self.reconnect_time = DEFAULT_RECONNECT_TIME
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
self.pong: datetime | None = None
self.websocket_alive: bool = False
+ self.websocket_callbacks: list[Callable[[bool], None]] = []
self._watchdog_task: asyncio.Task | None = None
@override
@@ -71,31 +70,31 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self._on_data_update()
super().async_update_listeners()
+ async def _async_setup(self) -> None:
+ """Initialize websocket connection and callbacks."""
+ await self.api.connect()
+ self.api.register_data_callback(self.handle_websocket_updates)
+
+ def start_watchdog() -> None:
+ if self._watchdog_task is not None and not self._watchdog_task.done():
+ _LOGGER.debug("Cancelling previous watchdog task")
+ self._watchdog_task.cancel()
+ self._watchdog_task = self.config_entry.async_create_background_task(
+ self.hass,
+ self._pong_watchdog(),
+ "websocket_watchdog",
+ )
+
+ self.api.register_ws_ready_callback(start_watchdog)
+
async def _async_update_data(self) -> MowerDictionary:
- """Subscribe for websocket and poll data from the API."""
- if not self.ws_connected:
- await self.api.connect()
- self.api.register_data_callback(self.handle_websocket_updates)
- self.ws_connected = True
-
- def start_watchdog() -> None:
- if self._watchdog_task is not None and not self._watchdog_task.done():
- _LOGGER.debug("Cancelling previous watchdog task")
- self._watchdog_task.cancel()
- self._watchdog_task = self.config_entry.async_create_background_task(
- self.hass,
- self._pong_watchdog(),
- "websocket_watchdog",
- )
-
- self.api.register_ws_ready_callback(start_watchdog)
+ """Poll data from the API."""
try:
- data = await self.api.get_status()
+ return await self.api.get_status()
except ApiError as err:
raise UpdateFailed(err) from err
except AuthError as err:
raise ConfigEntryAuthFailed(err) from err
- return data
@callback
def _on_data_update(self) -> None:
@@ -199,14 +198,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
)
async def _pong_watchdog(self) -> None:
+ """Watchdog to check for pong messages."""
_LOGGER.debug("Watchdog started")
try:
while True:
_LOGGER.debug("Sending ping")
- self.websocket_alive = await self.api.send_empty_message()
- _LOGGER.debug("Ping result: %s", self.websocket_alive)
+ is_alive = await self.api.send_empty_message()
+ _LOGGER.debug("Ping result: %s", is_alive)
+ if self.websocket_alive != is_alive:
+ self.websocket_alive = is_alive
+ for ws_callback in self.websocket_callbacks:
+ ws_callback(is_alive)
- await asyncio.sleep(60)
+ await asyncio.sleep(PING_INTERVAL)
_LOGGER.debug("Websocket alive %s", self.websocket_alive)
if not self.websocket_alive:
_LOGGER.debug("No pong received → restart polling")
diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py
index 8e2e48b940d..2d7edcf1c73 100644
--- a/homeassistant/components/husqvarna_automower/event.py
+++ b/homeassistant/components/husqvarna_automower/event.py
@@ -1,6 +1,7 @@
"""Creates the event entities for supported mowers."""
from collections.abc import Callable
+import logging
from aioautomower.model import SingleMessageData
@@ -18,6 +19,7 @@ from .const import ERROR_KEYS
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
+_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
ATTR_SEVERITY = "severity"
@@ -34,12 +36,13 @@ async def async_setup_entry(
"""Set up Automower message event entities.
Entities are created dynamically based on messages received from the API,
- but only for mowers that support message events.
+ but only for mowers that support message events after the WebSocket connection
+ is ready.
"""
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
- restored_mowers = {
+ restored_mowers: set[str] = {
entry.unique_id.removesuffix("_message")
for entry in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
@@ -47,14 +50,20 @@ async def async_setup_entry(
if entry.domain == EVENT_DOMAIN
}
- async_add_entities(
- AutomowerMessageEventEntity(mower_id, coordinator)
- for mower_id in restored_mowers
- if mower_id in coordinator.data
- )
+ @callback
+ def _on_ws_ready() -> None:
+ async_add_entities(
+ AutomowerMessageEventEntity(mower_id, coordinator, websocket_alive=True)
+ for mower_id in restored_mowers
+ if mower_id in coordinator.data
+ )
+ coordinator.api.unregister_ws_ready_callback(_on_ws_ready)
+
+ coordinator.api.register_ws_ready_callback(_on_ws_ready)
@callback
def _handle_message(msg: SingleMessageData) -> None:
+ """Add entity dynamically if a new mower sends messages."""
if msg.id in restored_mowers:
return
@@ -76,10 +85,22 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
+ *,
+ websocket_alive: bool | None = None,
) -> None:
"""Initialize Automower message event entity."""
super().__init__(mower_id, coordinator)
self._attr_unique_id = f"{mower_id}_message"
+ self.websocket_alive: bool = (
+ websocket_alive
+ if websocket_alive is not None
+ else coordinator.websocket_alive
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if the entity is available."""
+ return self.websocket_alive and self.mower_id in self.coordinator.data
@callback
def _handle(self, msg: SingleMessageData) -> None:
@@ -102,7 +123,17 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
"""Register callback when entity is added to hass."""
await super().async_added_to_hass()
self.coordinator.api.register_single_message_callback(self._handle)
+ self.coordinator.websocket_callbacks.append(self._handle_websocket_update)
async def async_will_remove_from_hass(self) -> None:
"""Unregister WebSocket callback when entity is removed."""
self.coordinator.api.unregister_single_message_callback(self._handle)
+ self.coordinator.websocket_callbacks.remove(self._handle_websocket_update)
+
+ def _handle_websocket_update(self, is_alive: bool) -> None:
+ """Handle websocket status changes."""
+ if self.websocket_alive == is_alive:
+ return
+ self.websocket_alive = is_alive
+ _LOGGER.debug("WebSocket status changed to %s, updating entity state", is_alive)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index 49eb364858f..03605cc738b 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
- "requirements": ["aioautomower==2.1.2"]
+ "requirements": ["aioautomower==2.2.1"]
}
diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py
index 4537dec0e28..89de3336440 100644
--- a/homeassistant/components/husqvarna_automower_ble/__init__.py
+++ b/homeassistant/components/husqvarna_automower_ble/__init__.py
@@ -9,11 +9,11 @@ from bleak_retry_connector import close_stale_connections_by_address, get_device
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, Platform
+from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
from .coordinator import HusqvarnaCoordinator
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
@@ -26,10 +26,18 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
+ if CONF_PIN not in entry.data:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="pin_required",
+ translation_placeholders={"domain_name": "Husqvarna Automower BLE"},
+ )
+
address = entry.data[CONF_ADDRESS]
+ pin = int(entry.data[CONF_PIN])
channel_id = entry.data[CONF_CLIENT_ID]
- mower = Mower(channel_id, address)
+ mower = Mower(channel_id, address, pin)
await close_stale_connections_by_address(address)
@@ -39,6 +47,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
hass, address, connectable=True
) or await get_device(address)
response_result = await mower.connect(device)
+ if response_result == ResponseResult.INVALID_PIN:
+ raise ConfigEntryAuthFailed(
+ f"Unable to connect to device {address} due to wrong PIN"
+ )
if response_result != ResponseResult.OK:
raise ConfigEntryNotReady(
f"Unable to connect to device {address}, mower returned {response_result}"
diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py
index 72835c22334..fdca16a2765 100644
--- a/homeassistant/components/husqvarna_automower_ble/config_flow.py
+++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py
@@ -2,41 +2,77 @@
from __future__ import annotations
+from collections.abc import Mapping
import random
from typing import Any
from automower_ble.mower import Mower
+from automower_ble.protocol import ResponseResult
from bleak import BleakError
+from bleak_retry_connector import get_device
+from gardena_bluetooth.const import ScanService
+from gardena_bluetooth.parse import ManufacturerData, ProductType
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothServiceInfo
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID
+from homeassistant.config_entries import SOURCE_BLUETOOTH, ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN
from .const import DOMAIN, LOGGER
+BLUETOOTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PIN): str,
+ }
+)
+
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_ADDRESS): str,
+ vol.Required(CONF_PIN): str,
+ }
+)
+
+REAUTH_SCHEMA = BLUETOOTH_SCHEMA
+
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
+ if ScanService not in discovery_info.service_uuids:
+ LOGGER.debug(
+ "Unsupported device, missing service %s: %s", ScanService, discovery_info
+ )
+ return False
- LOGGER.debug(
- "%s manufacturer data: %s",
- discovery_info.address,
- discovery_info.manufacturer_data,
- )
+ if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)):
+ LOGGER.debug(
+ "Unsupported device, missing manufacturer data %s: %s",
+ ManufacturerData.company,
+ discovery_info,
+ )
+ return False
- manufacturer = any(key == 1062 for key in discovery_info.manufacturer_data)
- service_husqvarna = any(
- service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4"
- for service in discovery_info.service_uuids
- )
- service_generic = any(
- service == "00001800-0000-1000-8000-00805f9b34fb"
- for service in discovery_info.service_uuids
- )
+ manufacturer_data = ManufacturerData.decode(data)
+ product_type = ProductType.from_manufacturer_data(manufacturer_data)
- return manufacturer and service_husqvarna and service_generic
+ # Some mowers only expose the serial number in the manufacturer data
+ # and not the product type, so we allow None here as well.
+ if product_type not in (ProductType.MOWER, None):
+ LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
+ return False
+
+ LOGGER.debug("Supported device: %s", manufacturer_data)
+ return True
+
+
+def _pin_valid(pin: str) -> bool:
+ """Check if the pin is valid."""
+ try:
+ int(pin)
+ except (TypeError, ValueError):
+ return False
+ return True
class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -44,9 +80,9 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- def __init__(self) -> None:
- """Initialize the config flow."""
- self.address: str | None
+ address: str | None = None
+ mower_name: str = ""
+ pin: str | None = None
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
@@ -57,65 +93,240 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
if not _is_supported(discovery_info):
return self.async_abort(reason="no_devices_found")
+ self.context["title_placeholders"] = {
+ "name": discovery_info.name,
+ "address": discovery_info.address,
+ }
self.address = discovery_info.address
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
- return await self.async_step_confirm()
+ return await self.async_step_bluetooth_confirm()
- async def async_step_confirm(
+ async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Confirm discovery."""
+ """Confirm Bluetooth discovery."""
assert self.address
-
- device = bluetooth.async_ble_device_from_address(
- self.hass, self.address, connectable=True
- )
- channel_id = random.randint(1, 0xFFFFFFFF)
-
- try:
- (manufacturer, device_type, model) = await Mower(
- channel_id, self.address
- ).probe_gatts(device)
- except (BleakError, TimeoutError) as exception:
- LOGGER.exception("Failed to connect to device: %s", exception)
- return self.async_abort(reason="cannot_connect")
-
- title = manufacturer + " " + device_type
-
- LOGGER.debug("Found device: %s", title)
+ errors: dict[str, str] = {}
if user_input is not None:
- return self.async_create_entry(
- title=title,
- data={CONF_ADDRESS: self.address, CONF_CLIENT_ID: channel_id},
- )
+ if not _pin_valid(user_input[CONF_PIN]):
+ errors["base"] = "invalid_pin"
+ else:
+ self.pin = user_input[CONF_PIN]
+ return await self.check_mower(user_input)
- self.context["title_placeholders"] = {
- "name": title,
- }
-
- self._set_confirm_only()
return self.async_show_form(
- step_id="confirm",
- description_placeholders=self.context["title_placeholders"],
+ step_id="bluetooth_confirm",
+ data_schema=self.add_suggested_values_to_schema(
+ BLUETOOTH_SCHEMA, user_input
+ ),
+ description_placeholders={"name": self.mower_name or self.address},
+ errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the initial step."""
+ """Handle the initial manual step."""
+ errors: dict[str, str] = {}
+
if user_input is not None:
- self.address = user_input[CONF_ADDRESS]
- await self.async_set_unique_id(self.address, raise_on_progress=False)
- self._abort_if_unique_id_configured()
- return await self.async_step_confirm()
+ if not _pin_valid(user_input[CONF_PIN]):
+ errors["base"] = "invalid_pin"
+ else:
+ self.address = user_input[CONF_ADDRESS]
+ self.pin = user_input[CONF_PIN]
+ await self.async_set_unique_id(self.address, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+ return await self.check_mower(user_input)
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_ADDRESS): str,
- },
- ),
+ data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input),
+ errors=errors,
+ )
+
+ async def probe_mower(self, device) -> str | None:
+ """Probe the mower to see if it exists."""
+ channel_id = random.randint(1, 0xFFFFFFFF)
+
+ assert self.address
+
+ try:
+ (manufacturer, device_type, _model) = await Mower(
+ channel_id, self.address
+ ).probe_gatts(device)
+ except (BleakError, TimeoutError) as exception:
+ LOGGER.exception("Failed to probe device (%s): %s", self.address, exception)
+ return None
+
+ title = manufacturer + " " + device_type
+
+ LOGGER.debug("Found device: %s", title)
+
+ return title
+
+ async def connect_mower(self, device) -> tuple[int, Mower]:
+ """Connect to the Mower."""
+ assert self.address
+ assert self.pin is not None
+
+ channel_id = random.randint(1, 0xFFFFFFFF)
+ mower = Mower(channel_id, self.address, int(self.pin))
+
+ return (channel_id, mower)
+
+ async def check_mower(
+ self,
+ user_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Check that the mower exists and is setup."""
+ assert self.address
+
+ device = bluetooth.async_ble_device_from_address(
+ self.hass, self.address, connectable=True
+ )
+
+ title = await self.probe_mower(device)
+ if title is None:
+ if self.source == SOURCE_BLUETOOTH:
+ return self.async_show_form(
+ step_id="bluetooth_confirm",
+ data_schema=BLUETOOTH_SCHEMA,
+ description_placeholders={"name": self.address},
+ errors={"base": "cannot_connect"},
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ USER_SCHEMA,
+ {
+ CONF_ADDRESS: self.address,
+ CONF_PIN: self.pin,
+ },
+ ),
+ errors={"base": "cannot_connect"},
+ )
+ self.mower_name = title
+
+ try:
+ errors: dict[str, str] = {}
+
+ (channel_id, mower) = await self.connect_mower(device)
+
+ response_result = await mower.connect(device)
+ await mower.disconnect()
+
+ if response_result is not ResponseResult.OK:
+ LOGGER.debug("cannot connect, response: %s", response_result)
+
+ if (
+ response_result is ResponseResult.INVALID_PIN
+ or response_result is ResponseResult.NOT_ALLOWED
+ ):
+ errors["base"] = "invalid_auth"
+ else:
+ errors["base"] = "cannot_connect"
+
+ if self.source == SOURCE_BLUETOOTH:
+ return self.async_show_form(
+ step_id="bluetooth_confirm",
+ data_schema=BLUETOOTH_SCHEMA,
+ description_placeholders={
+ "name": self.mower_name or self.address
+ },
+ errors=errors,
+ )
+
+ suggested_values = {}
+
+ if self.address:
+ suggested_values[CONF_ADDRESS] = self.address
+ if self.pin:
+ suggested_values[CONF_PIN] = self.pin
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ USER_SCHEMA, suggested_values
+ ),
+ errors=errors,
+ )
+ except (TimeoutError, BleakError):
+ return self.async_abort(reason="cannot_connect")
+
+ return self.async_create_entry(
+ title=title,
+ data={
+ CONF_ADDRESS: self.address,
+ CONF_CLIENT_ID: channel_id,
+ CONF_PIN: self.pin,
+ },
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauthentication upon an API authentication error."""
+ reauth_entry = self._get_reauth_entry()
+ self.address = reauth_entry.data[CONF_ADDRESS]
+ self.mower_name = reauth_entry.title
+ self.pin = reauth_entry.data.get(CONF_PIN, "")
+
+ self.context["title_placeholders"] = {
+ "name": self.mower_name,
+ "address": self.address,
+ }
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauthentication dialog."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None and not _pin_valid(user_input[CONF_PIN]):
+ errors["base"] = "invalid_pin"
+ elif user_input is not None:
+ reauth_entry = self._get_reauth_entry()
+ self.pin = user_input[CONF_PIN]
+
+ try:
+ assert self.address
+ device = bluetooth.async_ble_device_from_address(
+ self.hass, self.address, connectable=True
+ ) or await get_device(self.address)
+
+ mower = Mower(
+ reauth_entry.data[CONF_CLIENT_ID], self.address, int(self.pin)
+ )
+
+ response_result = await mower.connect(device)
+ await mower.disconnect()
+ if (
+ response_result is ResponseResult.INVALID_PIN
+ or response_result is ResponseResult.NOT_ALLOWED
+ ):
+ errors["base"] = "invalid_auth"
+ elif response_result is not ResponseResult.OK:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data=reauth_entry.data | {CONF_PIN: self.pin},
+ )
+
+ except (TimeoutError, BleakError):
+ # We don't want to abort a reauth flow when we can't connect, so
+ # we just show the form again with an error.
+ errors["base"] = "cannot_connect"
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=self.add_suggested_values_to_schema(
+ REAUTH_SCHEMA, {CONF_PIN: self.pin}
+ ),
+ description_placeholders={"name": self.mower_name},
+ errors=errors,
)
diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
index 78d39ddd96a..ffe05bac8a8 100644
--- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
@@ -73,6 +73,10 @@ class AutomowerLawnMower(HusqvarnaAutomowerBleEntity, LawnMowerEntity):
if state in (MowerState.STOPPED, MowerState.OFF, MowerState.WAIT_FOR_SAFETYPIN):
# This is actually stopped, but that isn't an option
return LawnMowerActivity.ERROR
+ if state == MowerState.PENDING_START and activity == MowerActivity.NONE:
+ # This happens when the mower is safety stopped and we try to send a
+ # command to start it.
+ return LawnMowerActivity.ERROR
if state in (
MowerState.RESTRICTED,
MowerState.IN_OPERATION,
diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json
index 50430c2a9fa..68cfd5e8486 100644
--- a/homeassistant/components/husqvarna_automower_ble/manifest.json
+++ b/homeassistant/components/husqvarna_automower_ble/manifest.json
@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
- "requirements": ["automower-ble==0.2.7"]
+ "requirements": ["automower-ble==0.2.7", "gardena-bluetooth==1.6.0"]
}
diff --git a/homeassistant/components/husqvarna_automower_ble/strings.json b/homeassistant/components/husqvarna_automower_ble/strings.json
index de0a140933a..64ae632330c 100644
--- a/homeassistant/components/husqvarna_automower_ble/strings.json
+++ b/homeassistant/components/husqvarna_automower_ble/strings.json
@@ -4,18 +4,49 @@
"step": {
"user": {
"data": {
- "address": "Device BLE address"
+ "address": "Device BLE address",
+ "pin": "Mower PIN"
+ },
+ "data_description": {
+ "pin": "The PIN used to secure the mower"
}
},
- "confirm": {
- "description": "Do you want to set up {name}? Make sure the mower is in pairing mode"
+ "bluetooth_confirm": {
+ "description": "Do you want to set up {name}?\nMake sure the mower is in pairing mode.",
+ "data": {
+ "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]"
+ },
+ "data_description": {
+ "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]"
+ }
+ },
+ "reauth_confirm": {
+ "description": "Please confirm the PIN for {name}.",
+ "data": {
+ "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]"
+ },
+ "data_description": {
+ "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]"
+ }
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "Ensure the mower is in pairing mode and try again. It can take a few attempts.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "not_allowed": "Unable to read data from the mower, this usually means it is not paired",
"unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "Unable to pair with device, ensure the PIN is correct and the mower is in pairing mode",
+ "invalid_pin": "The PIN must be a number"
+ }
+ },
+ "exceptions": {
+ "pin_required": {
+ "message": "PIN is required for {domain_name}"
}
}
}
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index f2177d2144a..b26255db3fa 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -122,11 +122,24 @@ async def async_setup_entry(
coordinators.main.new_zones_callbacks.append(_add_new_zones)
platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
platform.async_register_entity_service(
- SERVICE_START_WATERING, SCHEMA_START_WATERING, "start_watering"
+ SERVICE_RESUME,
+ None,
+ "resume",
+ entity_device_classes=(BinarySensorDeviceClass.RUNNING,),
+ )
+ platform.async_register_entity_service(
+ SERVICE_START_WATERING,
+ SCHEMA_START_WATERING,
+ "start_watering",
+ entity_device_classes=(BinarySensorDeviceClass.RUNNING,),
+ )
+ platform.async_register_entity_service(
+ SERVICE_SUSPEND,
+ SCHEMA_SUSPEND,
+ "suspend",
+ entity_device_classes=(BinarySensorDeviceClass.RUNNING,),
)
- platform.async_register_entity_service(SERVICE_SUSPEND, SCHEMA_SUSPEND, "suspend")
class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity):
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index a599ffa888e..703fed8d415 100644
--- a/homeassistant/components/hydrawise/manifest.json
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
- "requirements": ["pydrawise==2025.7.0"]
+ "requirements": ["pydrawise==2025.9.0"]
}
diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py
index 68a8a093c09..88c7e97a814 100644
--- a/homeassistant/components/iaqualink/__init__.py
+++ b/homeassistant/components/iaqualink/__init__.py
@@ -24,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.httpx_client import get_async_client
@@ -104,6 +105,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
f"Error while attempting to retrieve devices list: {svc_exception}"
) from svc_exception
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ name=system.name,
+ identifiers={(DOMAIN, system.serial)},
+ manufacturer="Jandy",
+ serial_number=system.serial,
+ )
+
for dev in devices.values():
if isinstance(dev, AqualinkThermostat):
runtime_data.thermostats += [dev]
diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py
index 0b3751e5fbc..c0f44946b77 100644
--- a/homeassistant/components/iaqualink/entity.py
+++ b/homeassistant/components/iaqualink/entity.py
@@ -29,6 +29,7 @@ class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity):
self._attr_unique_id = f"{dev.system.serial}_{dev.name}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
+ via_device=(DOMAIN, dev.system.serial),
manufacturer=dev.manufacturer,
model=dev.model,
name=dev.label,
diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json
index a0742865438..6e8ce312ad0 100644
--- a/homeassistant/components/iaqualink/manifest.json
+++ b/homeassistant/components/iaqualink/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
- "requirements": ["iaqualink==0.5.3", "h2==4.2.0"],
+ "requirements": ["iaqualink==0.6.0", "h2==4.3.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json
index 52d9004bc3f..339404ba558 100644
--- a/homeassistant/components/icloud/manifest.json
+++ b/homeassistant/components/icloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/icloud",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
- "requirements": ["pyicloud==1.0.0"]
+ "requirements": ["pyicloud==2.0.3"]
}
diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json
index fc78e8c2ba6..ae0cb662d17 100644
--- a/homeassistant/components/icloud/strings.json
+++ b/homeassistant/components/icloud/strings.json
@@ -6,7 +6,7 @@
"description": "Enter your credentials",
"data": {
"username": "[%key:common::config_flow::data::email%]",
- "password": "App-specific password",
+ "password": "Main password (MFA)",
"with_family": "With family"
}
},
@@ -14,7 +14,8 @@
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Your previously entered password for {username} is no longer working. Update your password to keep using this integration.",
"data": {
- "password": "App-specific password"
+ "username": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:component::icloud::config::step::user::data::password%]"
}
},
"trusted_device": {
@@ -25,8 +26,8 @@
}
},
"verification_code": {
- "title": "iCloud verification code",
- "description": "Please enter the verification code you just received from iCloud",
+ "title": "Apple Account code",
+ "description": "Please enter the verification code you just received from Apple",
"data": {
"verification_code": "Verification code"
}
@@ -39,18 +40,18 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "no_device": "None of your devices have \"Find my iPhone\" activated",
+ "no_device": "None of your devices have \"Find My\" activated",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"services": {
"update": {
"name": "Update",
- "description": "Asks for a state update of all devices linked to an iCloud account.",
+ "description": "Asks for a state update of all devices linked to an Apple Account.",
"fields": {
"account": {
"name": "Account",
- "description": "Your iCloud account username (email) or account name."
+ "description": "Your Apple Account username (email)."
}
}
},
diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py
index 1ea0efeef72..158812cf015 100644
--- a/homeassistant/components/idasen_desk/__init__.py
+++ b/homeassistant/components/idasen_desk/__init__.py
@@ -34,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -
raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(_async_update_listener))
@callback
def _async_bluetooth_callback(
@@ -64,13 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -
return True
-async def _async_update_listener(
- hass: HomeAssistant, entry: IdasenDeskConfigEntry
-) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py
index 5da3d57cf9a..f7b7edd2cc1 100644
--- a/homeassistant/components/idasen_desk/coordinator.py
+++ b/homeassistant/components/idasen_desk/coordinator.py
@@ -8,13 +8,16 @@ from idasen_ha import Desk
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
+UPDATE_DEBOUNCE_TIME = 0.2
+
class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Class to manage updates for the Idasen Desk."""
@@ -33,9 +36,22 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
hass, _LOGGER, config_entry=config_entry, name=config_entry.title
)
self.address = address
- self._expected_connected = False
+ self.desk = Desk(self._async_handle_update)
- self.desk = Desk(self.async_set_updated_data)
+ self._expected_connected = False
+ self._height: int | None = None
+
+ @callback
+ def async_update_data() -> None:
+ self.async_set_updated_data(self._height)
+
+ self._debouncer = Debouncer(
+ hass=self.hass,
+ logger=_LOGGER,
+ cooldown=UPDATE_DEBOUNCE_TIME,
+ immediate=True,
+ function=async_update_data,
+ )
async def async_connect(self) -> bool:
"""Connect to desk."""
@@ -60,3 +76,9 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Ensure that the desk is connected if that is the expected state."""
if self._expected_connected:
await self.async_connect()
+
+ @callback
+ def _async_handle_update(self, height: int | None) -> None:
+ """Handle an update from the desk."""
+ self._height = height
+ self._debouncer.async_schedule_call()
diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py
index 0a3b9bf9af7..7bf0060f593 100644
--- a/homeassistant/components/image/__init__.py
+++ b/homeassistant/components/image/__init__.py
@@ -105,6 +105,20 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
raise HomeAssistantError("Unable to get image")
+async def async_get_image(
+ hass: HomeAssistant,
+ entity_id: str,
+ timeout: int = 10,
+) -> Image:
+ """Fetch an image from an image entity."""
+ component = hass.data[DATA_COMPONENT]
+
+ if (image := component.get_entity(entity_id)) is None:
+ raise HomeAssistantError(f"Image entity {entity_id} not found")
+
+ return await _async_get_image(image, timeout)
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the image component."""
component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity](
diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py
index 2bf28d13fd2..ff86d4441e4 100644
--- a/homeassistant/components/image_upload/__init__.py
+++ b/homeassistant/components/image_upload/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util import dt as dt_util
-from .const import DOMAIN
+from .const import DOMAIN, FOLDER_IMAGE
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = "image"
@@ -45,7 +45,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Image integration."""
- image_dir = pathlib.Path(hass.config.path("image"))
+ image_dir = pathlib.Path(hass.config.path(FOLDER_IMAGE))
hass.data[DOMAIN] = storage_collection = ImageStorageCollection(hass, image_dir)
await storage_collection.async_load()
ImageUploadStorageCollectionWebsocket(
diff --git a/homeassistant/components/image_upload/const.py b/homeassistant/components/image_upload/const.py
index f7607f745c7..89981b9dc30 100644
--- a/homeassistant/components/image_upload/const.py
+++ b/homeassistant/components/image_upload/const.py
@@ -1,3 +1,4 @@
"""Constants for the Image Upload integration."""
DOMAIN = "image_upload"
+FOLDER_IMAGE = "image"
diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py
index ee9511e2c36..d1fc978c278 100644
--- a/homeassistant/components/image_upload/media_source.py
+++ b/homeassistant/components/image_upload/media_source.py
@@ -2,6 +2,10 @@
from __future__ import annotations
+import pathlib
+
+from propcache.api import cached_property
+
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
BrowseMediaSource,
@@ -12,7 +16,7 @@ from homeassistant.components.media_source import (
)
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .const import DOMAIN, FOLDER_IMAGE
async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource:
@@ -30,6 +34,11 @@ class ImageUploadMediaSource(MediaSource):
super().__init__(DOMAIN)
self.hass = hass
+ @cached_property
+ def image_folder(self) -> pathlib.Path:
+ """Return the image folder path."""
+ return pathlib.Path(self.hass.config.path(FOLDER_IMAGE))
+
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
image = self.hass.data[DOMAIN].data.get(item.identifier)
@@ -38,7 +47,9 @@ class ImageUploadMediaSource(MediaSource):
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
return PlayMedia(
- f"/api/image/serve/{image['id']}/original", image["content_type"]
+ f"/api/image/serve/{image['id']}/original",
+ image["content_type"],
+ path=self.image_folder / item.identifier / "original",
)
async def async_browse_media(
diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py
index 5349f249ab3..a60bc308410 100644
--- a/homeassistant/components/imap/__init__.py
+++ b/homeassistant/components/imap/__init__.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import asyncio
+from email.message import Message
import logging
+from typing import Any
from aioimaplib import IMAP4_SSL, AioImapException, Response
import voluptuous as vol
@@ -33,6 +35,7 @@ from .coordinator import (
ImapPollingDataUpdateCoordinator,
ImapPushDataUpdateCoordinator,
connect_to_server,
+ get_parts,
)
from .errors import InvalidAuth, InvalidFolder
@@ -40,6 +43,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
CONF_ENTRY = "entry"
CONF_SEEN = "seen"
+CONF_PART = "part"
CONF_UID = "uid"
CONF_TARGET_FOLDER = "target_folder"
@@ -64,6 +68,11 @@ SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend(
)
SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA
SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA
+SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend(
+ {
+ vol.Required(CONF_PART): cv.string,
+ }
+)
type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator]
@@ -216,12 +225,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
translation_placeholders={"error": str(exc)},
) from exc
raise_on_error(response, "fetch_failed")
+ # Index 1 of of the response lines contains the bytearray with the message data
message = ImapMessage(response.lines[1])
await client.close()
return {
"text": message.text,
"sender": message.sender,
"subject": message.subject,
+ "parts": get_parts(message.email_message),
"uid": uid,
}
@@ -233,6 +244,73 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
supports_response=SupportsResponse.ONLY,
)
+ async def async_fetch_part(call: ServiceCall) -> ServiceResponse:
+ """Process fetch email part service and return content."""
+
+ @callback
+ def get_message_part(message: Message, part_key: str) -> Message:
+ part: Message | Any = message
+ for index in part_key.split(","):
+ sub_parts = part.get_payload()
+ try:
+ assert isinstance(sub_parts, list)
+ part = sub_parts[int(index)]
+ except (AssertionError, ValueError, IndexError) as exc:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_part_index",
+ ) from exc
+
+ return part
+
+ entry_id: str = call.data[CONF_ENTRY]
+ uid: str = call.data[CONF_UID]
+ part_key: str = call.data[CONF_PART]
+ _LOGGER.debug(
+ "Fetch part %s for message %s. Entry: %s",
+ part_key,
+ uid,
+ entry_id,
+ )
+ client = await async_get_imap_client(hass, entry_id)
+ try:
+ response = await client.fetch(uid, "BODY.PEEK[]")
+ except (TimeoutError, AioImapException) as exc:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="imap_server_fail",
+ translation_placeholders={"error": str(exc)},
+ ) from exc
+ raise_on_error(response, "fetch_failed")
+ # Index 1 of of the response lines contains the bytearray with the message data
+ message = ImapMessage(response.lines[1])
+ await client.close()
+ part_data = get_message_part(message.email_message, part_key)
+ part_data_content = part_data.get_payload(decode=False)
+ try:
+ assert isinstance(part_data_content, str)
+ except AssertionError as exc:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_part_index",
+ ) from exc
+ return {
+ "part_data": part_data_content,
+ "content_type": part_data.get_content_type(),
+ "content_transfer_encoding": part_data.get("Content-Transfer-Encoding"),
+ "filename": part_data.get_filename(),
+ "part": part_key,
+ "uid": uid,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ "fetch_part",
+ async_fetch_part,
+ SERVICE_FETCH_PART_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+
return True
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index 34d3f43eb69..af8fcc91155 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
CONTENT_TYPE_TEXT_PLAIN,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
@@ -209,6 +209,28 @@ class ImapMessage:
return str(self.email_message.get_payload())
+@callback
+def get_parts(message: Message, prefix: str | None = None) -> dict[str, Any]:
+ """Return information about the parts of a multipart message."""
+ parts: dict[str, Any] = {}
+ if not message.is_multipart():
+ return {}
+ for index, part in enumerate(message.get_payload(), 0):
+ if TYPE_CHECKING:
+ assert isinstance(part, Message)
+ key = f"{prefix},{index}" if prefix else f"{index}"
+ if part.is_multipart():
+ parts |= get_parts(part, key)
+ continue
+ parts[key] = {"content_type": part.get_content_type()}
+ if filename := part.get_filename():
+ parts[key]["filename"] = filename
+ if content_transfer_encoding := part.get("Content-Transfer-Encoding"):
+ parts[key]["content_transfer_encoding"] = content_transfer_encoding
+
+ return parts
+
+
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""
@@ -275,6 +297,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"sender": message.sender,
"subject": message.subject,
"uid": last_message_uid,
+ "parts": get_parts(message.email_message),
}
data.update({key: getattr(message, key) for key in self._event_data_keys})
if self.custom_event_template is not None:
diff --git a/homeassistant/components/imap/icons.json b/homeassistant/components/imap/icons.json
index 17a11d0fe22..5c134b8ef81 100644
--- a/homeassistant/components/imap/icons.json
+++ b/homeassistant/components/imap/icons.json
@@ -21,6 +21,9 @@
},
"fetch": {
"service": "mdi:email-sync-outline"
+ },
+ "fetch_part": {
+ "service": "mdi:email-sync-outline"
}
}
}
diff --git a/homeassistant/components/imap/services.yaml b/homeassistant/components/imap/services.yaml
index be56eb148da..7854a6fd688 100644
--- a/homeassistant/components/imap/services.yaml
+++ b/homeassistant/components/imap/services.yaml
@@ -56,3 +56,22 @@ fetch:
example: "12"
selector:
text:
+
+fetch_part:
+ fields:
+ entry:
+ required: true
+ selector:
+ config_entry:
+ integration: "imap"
+ uid:
+ required: true
+ example: "12"
+ selector:
+ text:
+
+ part:
+ required: true
+ example: "0,1"
+ selector:
+ text:
diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json
index 0f6f99dff65..417afcf1756 100644
--- a/homeassistant/components/imap/strings.json
+++ b/homeassistant/components/imap/strings.json
@@ -84,6 +84,9 @@
"imap_server_fail": {
"message": "The IMAP server failed to connect: {error}."
},
+ "invalid_part_index": {
+ "message": "Invalid part index."
+ },
"seen_failed": {
"message": "Marking message as seen failed with \"{error}\"."
}
@@ -148,6 +151,24 @@
}
}
},
+ "fetch_part": {
+ "name": "Fetch message part",
+ "description": "Fetches a message part or attachment from an email message.",
+ "fields": {
+ "entry": {
+ "name": "[%key:component::imap::services::fetch::fields::entry::name%]",
+ "description": "[%key:component::imap::services::fetch::fields::entry::description%]"
+ },
+ "uid": {
+ "name": "[%key:component::imap::services::fetch::fields::uid::name%]",
+ "description": "[%key:component::imap::services::fetch::fields::uid::description%]"
+ },
+ "part": {
+ "name": "Part",
+ "description": "The message part index."
+ }
+ }
+ },
"seen": {
"name": "Mark message as seen",
"description": "Marks an email as seen.",
diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py
index 9cde40e01d7..da4cd7d381e 100644
--- a/homeassistant/components/imeon_inverter/const.py
+++ b/homeassistant/components/imeon_inverter/const.py
@@ -5,14 +5,23 @@ from homeassistant.const import Platform
DOMAIN = "imeon_inverter"
TIMEOUT = 30
PLATFORMS = [
+ Platform.SELECT,
Platform.SENSOR,
]
ATTR_BATTERY_STATUS = ["charging", "discharging", "charged"]
+ATTR_INVERTER_MODE = {
+ "smg": "smart_grid",
+ "bup": "backup",
+ "ong": "on_grid",
+ "ofg": "off_grid",
+}
+INVERTER_MODE_OPTIONS = {v: k for k, v in ATTR_INVERTER_MODE.items()}
ATTR_INVERTER_STATE = [
+ "not_connected",
"unsynchronized",
"grid_consumption",
"grid_injection",
- "grid_synchronised_but_not_used",
+ "grid_synchronized_but_not_used",
]
ATTR_TIMELINE_STATUS = [
"com_lost",
diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json
index a4a7edf21a6..34ecd9d7923 100644
--- a/homeassistant/components/imeon_inverter/icons.json
+++ b/homeassistant/components/imeon_inverter/icons.json
@@ -169,6 +169,17 @@
},
"energy_battery_consumed": {
"default": "mdi:battery-arrow-down-outline"
+ },
+ "forecast_cons_remaining_today": {
+ "default": "mdi:chart-line"
+ },
+ "forecast_prod_remaining_today": {
+ "default": "mdi:chart-line"
+ }
+ },
+ "select": {
+ "manager_inverter_mode": {
+ "default": "mdi:view-grid"
}
}
}
diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json
index a9a37f3fd9c..ed24d169d63 100644
--- a/homeassistant/components/imeon_inverter/manifest.json
+++ b/homeassistant/components/imeon_inverter/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
- "requirements": ["imeon_inverter_api==0.3.14"],
+ "requirements": ["imeon_inverter_api==0.4.0"],
"ssdp": [
{
"manufacturer": "IMEON",
diff --git a/homeassistant/components/imeon_inverter/select.py b/homeassistant/components/imeon_inverter/select.py
new file mode 100644
index 00000000000..def8b1b0e0a
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/select.py
@@ -0,0 +1,72 @@
+"""Imeon inverter select support."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+import logging
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import ATTR_INVERTER_MODE, INVERTER_MODE_OPTIONS
+from .coordinator import Inverter, InverterCoordinator
+from .entity import InverterEntity
+
+type InverterConfigEntry = ConfigEntry[InverterCoordinator]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class ImeonSelectEntityDescription(SelectEntityDescription):
+ """Class to describe an Imeon inverter select entity."""
+
+ set_value_fn: Callable[[Inverter, str], Awaitable[bool]]
+ values: dict[str, str]
+
+
+SELECT_DESCRIPTIONS: tuple[ImeonSelectEntityDescription, ...] = (
+ ImeonSelectEntityDescription(
+ key="manager_inverter_mode",
+ translation_key="manager_inverter_mode",
+ options=list(INVERTER_MODE_OPTIONS),
+ values=ATTR_INVERTER_MODE,
+ set_value_fn=lambda api, mode: api.set_inverter_mode(
+ INVERTER_MODE_OPTIONS[mode]
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: InverterConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Create each select for a given config entry."""
+ coordinator = entry.runtime_data
+ async_add_entities(
+ InverterSelect(coordinator, entry, description)
+ for description in SELECT_DESCRIPTIONS
+ )
+
+
+class InverterSelect(InverterEntity, SelectEntity):
+ """Representation of an Imeon inverter select."""
+
+ entity_description: ImeonSelectEntityDescription
+ _attr_entity_category = EntityCategory.CONFIG
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the state of the select."""
+ value = self.coordinator.data.get(self.data_key)
+ if not isinstance(value, str):
+ return None
+ return self.entity_description.values.get(value)
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.entity_description.set_value_fn(self.coordinator.api, option)
diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py
index 21aa37a0523..3aa26f4a3c3 100644
--- a/homeassistant/components/imeon_inverter/sensor.py
+++ b/homeassistant/components/imeon_inverter/sensor.py
@@ -417,6 +417,21 @@ SENSOR_DESCRIPTIONS = (
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
),
+ # Forecast
+ SensorEntityDescription(
+ key="forecast_cons_remaining_today",
+ translation_key="forecast_cons_remaining_today",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="forecast_prod_remaining_today",
+ translation_key="forecast_prod_remaining_today",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ suggested_display_precision=2,
+ ),
)
diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json
index 66d0472b89a..50ca969746d 100644
--- a/homeassistant/components/imeon_inverter/strings.json
+++ b/homeassistant/components/imeon_inverter/strings.json
@@ -91,10 +91,11 @@
"manager_inverter_state": {
"name": "Inverter state",
"state": {
+ "not_connected": "Not connected",
"unsynchronized": "Unsynchronized",
"grid_consumption": "Grid consumption",
"grid_injection": "Grid injection",
- "grid_synchronised_but_not_used": "Grid unsynchronized but used"
+ "grid_synchronized_but_not_used": "Grid unsynchronized but used"
}
},
"meter_power": {
@@ -212,6 +213,23 @@
},
"energy_battery_consumed": {
"name": "Today battery-consumed energy"
+ },
+ "forecast_cons_remaining_today": {
+ "name": "Forecast remaining energy consumption for today"
+ },
+ "forecast_prod_remaining_today": {
+ "name": "Forecast remaining energy production for today"
+ }
+ },
+ "select": {
+ "manager_inverter_mode": {
+ "name": "Inverter mode",
+ "state": {
+ "smart_grid": "Smart grid",
+ "backup": "Backup",
+ "on_grid": "On-grid",
+ "off_grid": "Off-grid"
+ }
}
}
}
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index b0779b35f14..6bfb9cd4324 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
- "requirements": ["imgw_pib==1.5.4"]
+ "requirements": ["imgw_pib==1.5.6"]
}
diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py
index 008a807c0d2..8e824b100bc 100644
--- a/homeassistant/components/immich/media_source.py
+++ b/homeassistant/components/immich/media_source.py
@@ -9,9 +9,8 @@ from aioimmich.assets.models import ImmichAsset
from aioimmich.exceptions import ImmichError
from homeassistant.components.http import HomeAssistantView
-from homeassistant.components.media_player import MediaClass
+from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
- BrowseError,
BrowseMediaSource,
MediaSource,
MediaSourceItem,
diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json
index be157b8070d..9bf340b9abe 100644
--- a/homeassistant/components/improv_ble/strings.json
+++ b/homeassistant/components/improv_ble/strings.json
@@ -42,7 +42,7 @@
"characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"provision_successful": "The device has successfully connected to the Wi-Fi network.",
- "provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.",
+ "provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease finish the setup by following the [setup instructions]({url}).",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py
index 8daa94f2f6d..3df99e55aec 100644
--- a/homeassistant/components/inkbird/__init__.py
+++ b/homeassistant/components/inkbird/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
-INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
+type INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]
diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml
index 04a09e5366a..668f5b97d23 100644
--- a/homeassistant/components/input_select/services.yaml
+++ b/homeassistant/components/input_select/services.yaml
@@ -17,7 +17,10 @@ select_option:
required: true
example: '"Item A"'
selector:
- text:
+ state:
+ hide_states:
+ - unavailable
+ - unknown
select_previous:
target:
diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py
index 82f44578aed..b03baf32e91 100644
--- a/homeassistant/components/integration/__init__.py
+++ b/homeassistant/components/integration/__init__.py
@@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -51,7 +52,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -89,13 +89,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- # Remove device link for entry, the source device may have changed.
- # The link will be recreated after load.
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py
index 329abdbea87..370de8b8011 100644
--- a/homeassistant/components/integration/config_flow.py
+++ b/homeassistant/components/integration/config_flow.py
@@ -151,6 +151,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index 72853276ab3..17ec8602d98 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -615,7 +615,7 @@ class IntentHandleView(http.HomeAssistantView):
intent_result = await intent.async_handle(
hass, DOMAIN, intent_name, slots, "", self.context(request)
)
- except intent.IntentHandleError as err:
+ except (intent.IntentHandleError, intent.MatchFailedError) as err:
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech(str(err))
diff --git a/homeassistant/components/intent/manifest.json b/homeassistant/components/intent/manifest.json
index 90f7a34e624..9bb580dd842 100644
--- a/homeassistant/components/intent/manifest.json
+++ b/homeassistant/components/intent/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "intent",
"name": "Intent",
- "codeowners": ["@home-assistant/core", "@synesthesiam"],
+ "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"config_flow": false,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/intent",
diff --git a/homeassistant/components/irm_kmi/__init__.py b/homeassistant/components/irm_kmi/__init__.py
new file mode 100644
index 00000000000..3ca71f61cd6
--- /dev/null
+++ b/homeassistant/components/irm_kmi/__init__.py
@@ -0,0 +1,40 @@
+"""Integration for IRM KMI weather."""
+
+import logging
+
+from irm_kmi_api import IrmKmiApiClientHa
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import IRM_KMI_TO_HA_CONDITION_MAP, PLATFORMS, USER_AGENT
+from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
+ """Set up this integration using UI."""
+ api_client = IrmKmiApiClientHa(
+ session=async_get_clientsession(hass),
+ user_agent=USER_AGENT,
+ cdt_map=IRM_KMI_TO_HA_CONDITION_MAP,
+ )
+
+ entry.runtime_data = IrmKmiCoordinator(hass, entry, api_client)
+
+ await entry.runtime_data.async_config_entry_first_refresh()
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
+ """Handle removal of an entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_reload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> None:
+ """Reload config entry."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/irm_kmi/config_flow.py b/homeassistant/components/irm_kmi/config_flow.py
new file mode 100644
index 00000000000..ad426b36ba5
--- /dev/null
+++ b/homeassistant/components/irm_kmi/config_flow.py
@@ -0,0 +1,132 @@
+"""Config flow to set up IRM KMI integration via the UI."""
+
+import logging
+
+from irm_kmi_api import IrmKmiApiClient, IrmKmiApiError
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+ OptionsFlowWithReload,
+)
+from homeassistant.const import (
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ CONF_LOCATION,
+ CONF_UNIQUE_ID,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ LocationSelector,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import (
+ CONF_LANGUAGE_OVERRIDE,
+ CONF_LANGUAGE_OVERRIDE_OPTIONS,
+ DOMAIN,
+ OUT_OF_BENELUX,
+ USER_AGENT,
+)
+from .coordinator import IrmKmiConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Configuration flow for the IRM KMI integration."""
+
+ VERSION = 1
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(_config_entry: IrmKmiConfigEntry) -> OptionsFlow:
+ """Create the options flow."""
+ return IrmKmiOptionFlow()
+
+ async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
+ """Define the user step of the configuration flow."""
+ errors: dict = {}
+
+ default_location = {
+ ATTR_LATITUDE: self.hass.config.latitude,
+ ATTR_LONGITUDE: self.hass.config.longitude,
+ }
+
+ if user_input:
+ _LOGGER.debug("Provided config user is: %s", user_input)
+
+ lat: float = user_input[CONF_LOCATION][ATTR_LATITUDE]
+ lon: float = user_input[CONF_LOCATION][ATTR_LONGITUDE]
+
+ try:
+ api_data = await IrmKmiApiClient(
+ session=async_get_clientsession(self.hass),
+ user_agent=USER_AGENT,
+ ).get_forecasts_coord({"lat": lat, "long": lon})
+ except IrmKmiApiError:
+ _LOGGER.exception(
+ "Encountered an unexpected error while configuring the integration"
+ )
+ return self.async_abort(reason="api_error")
+
+ if api_data["cityName"] in OUT_OF_BENELUX:
+ errors[CONF_LOCATION] = "out_of_benelux"
+
+ if not errors:
+ name: str = api_data["cityName"]
+ country: str = api_data["country"]
+ unique_id: str = f"{name.lower()} {country.lower()}"
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+ user_input[CONF_UNIQUE_ID] = unique_id
+
+ return self.async_create_entry(title=name, data=user_input)
+
+ default_location = user_input[CONF_LOCATION]
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_LOCATION, default=default_location
+ ): LocationSelector()
+ }
+ ),
+ errors=errors,
+ )
+
+
+class IrmKmiOptionFlow(OptionsFlowWithReload):
+ """Option flow for the IRM KMI integration, help change the options once the integration was configured."""
+
+ async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
+ """Manage the options."""
+ if user_input is not None:
+ _LOGGER.debug("Provided config user is: %s", user_input)
+ return self.async_create_entry(data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_LANGUAGE_OVERRIDE,
+ default=self.config_entry.options.get(
+ CONF_LANGUAGE_OVERRIDE, "none"
+ ),
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_LANGUAGE_OVERRIDE,
+ )
+ )
+ }
+ ),
+ )
diff --git a/homeassistant/components/irm_kmi/const.py b/homeassistant/components/irm_kmi/const.py
new file mode 100644
index 00000000000..afffc0fd242
--- /dev/null
+++ b/homeassistant/components/irm_kmi/const.py
@@ -0,0 +1,102 @@
+"""Constants for the IRM KMI integration."""
+
+from typing import Final
+
+from homeassistant.components.weather import (
+ ATTR_CONDITION_CLEAR_NIGHT,
+ ATTR_CONDITION_CLOUDY,
+ ATTR_CONDITION_FOG,
+ ATTR_CONDITION_LIGHTNING_RAINY,
+ ATTR_CONDITION_PARTLYCLOUDY,
+ ATTR_CONDITION_POURING,
+ ATTR_CONDITION_RAINY,
+ ATTR_CONDITION_SNOWY,
+ ATTR_CONDITION_SNOWY_RAINY,
+ ATTR_CONDITION_SUNNY,
+)
+from homeassistant.const import Platform, __version__
+
+DOMAIN: Final = "irm_kmi"
+PLATFORMS: Final = [Platform.WEATHER]
+
+OUT_OF_BENELUX: Final = [
+ "außerhalb der Benelux (Brussels)",
+ "Hors de Belgique (Bxl)",
+ "Outside the Benelux (Brussels)",
+ "Buiten de Benelux (Brussel)",
+]
+LANGS: Final = ["en", "fr", "nl", "de"]
+
+CONF_LANGUAGE_OVERRIDE: Final = "language_override"
+CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = ["none", "fr", "nl", "de", "en"]
+
+# Dict to map ('ww', 'dayNight') tuple from IRM KMI to HA conditions.
+IRM_KMI_TO_HA_CONDITION_MAP: Final = {
+ (0, "d"): ATTR_CONDITION_SUNNY,
+ (0, "n"): ATTR_CONDITION_CLEAR_NIGHT,
+ (1, "d"): ATTR_CONDITION_SUNNY,
+ (1, "n"): ATTR_CONDITION_CLEAR_NIGHT,
+ (2, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (2, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (3, "d"): ATTR_CONDITION_PARTLYCLOUDY,
+ (3, "n"): ATTR_CONDITION_PARTLYCLOUDY,
+ (4, "d"): ATTR_CONDITION_POURING,
+ (4, "n"): ATTR_CONDITION_POURING,
+ (5, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (5, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (6, "d"): ATTR_CONDITION_POURING,
+ (6, "n"): ATTR_CONDITION_POURING,
+ (7, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (7, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (8, "d"): ATTR_CONDITION_SNOWY_RAINY,
+ (8, "n"): ATTR_CONDITION_SNOWY_RAINY,
+ (9, "d"): ATTR_CONDITION_SNOWY_RAINY,
+ (9, "n"): ATTR_CONDITION_SNOWY_RAINY,
+ (10, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (10, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (11, "d"): ATTR_CONDITION_SNOWY,
+ (11, "n"): ATTR_CONDITION_SNOWY,
+ (12, "d"): ATTR_CONDITION_SNOWY,
+ (12, "n"): ATTR_CONDITION_SNOWY,
+ (13, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (13, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (14, "d"): ATTR_CONDITION_CLOUDY,
+ (14, "n"): ATTR_CONDITION_CLOUDY,
+ (15, "d"): ATTR_CONDITION_CLOUDY,
+ (15, "n"): ATTR_CONDITION_CLOUDY,
+ (16, "d"): ATTR_CONDITION_POURING,
+ (16, "n"): ATTR_CONDITION_POURING,
+ (17, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (17, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
+ (18, "d"): ATTR_CONDITION_RAINY,
+ (18, "n"): ATTR_CONDITION_RAINY,
+ (19, "d"): ATTR_CONDITION_POURING,
+ (19, "n"): ATTR_CONDITION_POURING,
+ (20, "d"): ATTR_CONDITION_SNOWY_RAINY,
+ (20, "n"): ATTR_CONDITION_SNOWY_RAINY,
+ (21, "d"): ATTR_CONDITION_RAINY,
+ (21, "n"): ATTR_CONDITION_RAINY,
+ (22, "d"): ATTR_CONDITION_SNOWY,
+ (22, "n"): ATTR_CONDITION_SNOWY,
+ (23, "d"): ATTR_CONDITION_SNOWY,
+ (23, "n"): ATTR_CONDITION_SNOWY,
+ (24, "d"): ATTR_CONDITION_FOG,
+ (24, "n"): ATTR_CONDITION_FOG,
+ (25, "d"): ATTR_CONDITION_FOG,
+ (25, "n"): ATTR_CONDITION_FOG,
+ (26, "d"): ATTR_CONDITION_FOG,
+ (26, "n"): ATTR_CONDITION_FOG,
+ (27, "d"): ATTR_CONDITION_FOG,
+ (27, "n"): ATTR_CONDITION_FOG,
+}
+
+IRM_KMI_NAME: Final = {
+ "fr": "Institut Royal Météorologique de Belgique",
+ "nl": "Koninklijk Meteorologisch Instituut van België",
+ "de": "Königliche Meteorologische Institut von Belgien",
+ "en": "Royal Meteorological Institute of Belgium",
+}
+
+USER_AGENT: Final = (
+ f"https://www.home-assistant.io/integrations/irm_kmi (version {__version__})"
+)
diff --git a/homeassistant/components/irm_kmi/coordinator.py b/homeassistant/components/irm_kmi/coordinator.py
new file mode 100644
index 00000000000..9ff6d735cdd
--- /dev/null
+++ b/homeassistant/components/irm_kmi/coordinator.py
@@ -0,0 +1,95 @@
+"""DataUpdateCoordinator for the IRM KMI integration."""
+
+from datetime import timedelta
+import logging
+
+from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_LOCATION
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import (
+ TimestampDataUpdateCoordinator,
+ UpdateFailed,
+)
+from homeassistant.util import dt as dt_util
+from homeassistant.util.dt import utcnow
+
+from .data import ProcessedCoordinatorData
+from .utils import preferred_language
+
+_LOGGER = logging.getLogger(__name__)
+
+type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator]
+
+
+class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]):
+ """Coordinator to update data from IRM KMI."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: IrmKmiConfigEntry,
+ api_client: IrmKmiApiClientHa,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name="IRM KMI weather",
+ update_interval=timedelta(minutes=7),
+ )
+ self._api = api_client
+ self._location = entry.data[CONF_LOCATION]
+
+ async def _async_update_data(self) -> ProcessedCoordinatorData:
+ """Fetch data from API endpoint.
+
+ This is the place to pre-process the data to lookup tables so entities can quickly look up their data.
+ :return: ProcessedCoordinatorData
+ """
+
+ self._api.expire_cache()
+
+ try:
+ await self._api.refresh_forecasts_coord(
+ {
+ "lat": self._location[ATTR_LATITUDE],
+ "long": self._location[ATTR_LONGITUDE],
+ }
+ )
+
+ except IrmKmiApiError as err:
+ if (
+ self.last_update_success_time is not None
+ and self.update_interval is not None
+ and self.last_update_success_time - utcnow()
+ < timedelta(seconds=2.5 * self.update_interval.seconds)
+ ):
+ return self.data
+
+ _LOGGER.warning(
+ "Could not connect to the API since %s", self.last_update_success_time
+ )
+ raise UpdateFailed(
+ f"Error communicating with API for general forecast: {err}. "
+ f"Last success time is: {self.last_update_success_time}"
+ ) from err
+
+ if not self.last_update_success:
+ _LOGGER.warning("Successfully reconnected to the API")
+
+ return await self.process_api_data()
+
+ async def process_api_data(self) -> ProcessedCoordinatorData:
+ """From the API data, create the object that will be used in the entities."""
+ tz = await dt_util.async_get_time_zone("Europe/Brussels")
+ lang = preferred_language(self.hass, self.config_entry)
+
+ return ProcessedCoordinatorData(
+ current_weather=self._api.get_current_weather(tz),
+ daily_forecast=self._api.get_daily_forecast(tz, lang),
+ hourly_forecast=self._api.get_hourly_forecast(tz),
+ country=self._api.get_country(),
+ )
diff --git a/homeassistant/components/irm_kmi/data.py b/homeassistant/components/irm_kmi/data.py
new file mode 100644
index 00000000000..5a70b97f36f
--- /dev/null
+++ b/homeassistant/components/irm_kmi/data.py
@@ -0,0 +1,17 @@
+"""Define data classes for the IRM KMI integration."""
+
+from dataclasses import dataclass, field
+
+from irm_kmi_api import CurrentWeatherData, ExtendedForecast
+
+from homeassistant.components.weather import Forecast
+
+
+@dataclass
+class ProcessedCoordinatorData:
+ """Dataclass that will be exposed to the entities consuming data from an IrmKmiCoordinator."""
+
+ current_weather: CurrentWeatherData
+ country: str
+ hourly_forecast: list[Forecast] = field(default_factory=list)
+ daily_forecast: list[ExtendedForecast] = field(default_factory=list)
diff --git a/homeassistant/components/irm_kmi/entity.py b/homeassistant/components/irm_kmi/entity.py
new file mode 100644
index 00000000000..a35c04ac425
--- /dev/null
+++ b/homeassistant/components/irm_kmi/entity.py
@@ -0,0 +1,28 @@
+"""Base class shared among IRM KMI entities."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, IRM_KMI_NAME
+from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
+from .utils import preferred_language
+
+
+class IrmKmiBaseEntity(CoordinatorEntity[IrmKmiCoordinator]):
+ """Base methods for IRM KMI entities."""
+
+ _attr_attribution = (
+ "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
+ )
+ _attr_has_entity_name = True
+
+ def __init__(self, entry: IrmKmiConfigEntry) -> None:
+ """Init base properties for IRM KMI entities."""
+ coordinator = entry.runtime_data
+ super().__init__(coordinator)
+
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, entry.entry_id)},
+ manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)),
+ )
diff --git a/homeassistant/components/irm_kmi/manifest.json b/homeassistant/components/irm_kmi/manifest.json
new file mode 100644
index 00000000000..f79819f5e83
--- /dev/null
+++ b/homeassistant/components/irm_kmi/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "irm_kmi",
+ "name": "IRM KMI Weather Belgium",
+ "codeowners": ["@jdejaegh"],
+ "config_flow": true,
+ "dependencies": ["zone"],
+ "documentation": "https://www.home-assistant.io/integrations/irm_kmi",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["irm_kmi_api"],
+ "quality_scale": "bronze",
+ "requirements": ["irm-kmi-api==1.1.0"]
+}
diff --git a/homeassistant/components/irm_kmi/quality_scale.yaml b/homeassistant/components/irm_kmi/quality_scale.yaml
new file mode 100644
index 00000000000..15e34719025
--- /dev/null
+++ b/homeassistant/components/irm_kmi/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: >
+ No service action implemented in this integration at the moment.
+ appropriate-polling:
+ status: done
+ comment: >
+ Polling interval is set to 7 minutes.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: >
+ No service action implemented in this integration at the moment.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: >
+ No service action implemented in this integration at the moment.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: >
+ There is no authentication for this integration
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: >
+ The integration does not look for devices on the network. It uses an online API.
+ discovery:
+ status: exempt
+ comment: >
+ The integration does not look for devices on the network. It uses an online API.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: >
+ This integration does not integrate physical devices.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: done
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow:
+ status: exempt
+ comment: >
+ There is no configuration per se, just a zone to pick.
+ repair-issues: done
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/irm_kmi/strings.json b/homeassistant/components/irm_kmi/strings.json
new file mode 100644
index 00000000000..810b61fc276
--- /dev/null
+++ b/homeassistant/components/irm_kmi/strings.json
@@ -0,0 +1,50 @@
+{
+ "title": "Royal Meteorological Institute of Belgium",
+ "common": {
+ "language_override_description": "Override the Home Assistant language for the textual weather forecast."
+ },
+ "config": {
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
+ "api_error": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "location": "[%key:common::config_flow::data::location%]"
+ },
+ "data_description": {
+ "location": "[%key:common::config_flow::data::location%]"
+ }
+ }
+ },
+ "error": {
+ "out_of_benelux": "The location is outside of Benelux. Pick a location in Benelux."
+ }
+ },
+ "selector": {
+ "language_override": {
+ "options": {
+ "none": "Follow Home Assistant server language",
+ "fr": "French",
+ "nl": "Dutch",
+ "de": "German",
+ "en": "English"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Options",
+ "data": {
+ "language_override": "[%key:common::config_flow::data::language%]"
+ },
+ "data_description": {
+ "language_override": "[%key:component::irm_kmi::common::language_override_description%]"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/irm_kmi/utils.py b/homeassistant/components/irm_kmi/utils.py
new file mode 100644
index 00000000000..b5f36297696
--- /dev/null
+++ b/homeassistant/components/irm_kmi/utils.py
@@ -0,0 +1,18 @@
+"""Helper functions for use with IRM KMI integration."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_LANGUAGE_OVERRIDE, LANGS
+
+
+def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry | None) -> str:
+ """Get the preferred language for the integration if it was overridden by the configuration."""
+
+ if (
+ config_entry is None
+ or config_entry.options.get(CONF_LANGUAGE_OVERRIDE) == "none"
+ ):
+ return hass.config.language if hass.config.language in LANGS else "en"
+
+ return config_entry.options.get(CONF_LANGUAGE_OVERRIDE, "en")
diff --git a/homeassistant/components/irm_kmi/weather.py b/homeassistant/components/irm_kmi/weather.py
new file mode 100644
index 00000000000..a0b4286a50c
--- /dev/null
+++ b/homeassistant/components/irm_kmi/weather.py
@@ -0,0 +1,158 @@
+"""Support for IRM KMI weather."""
+
+from irm_kmi_api import CurrentWeatherData
+
+from homeassistant.components.weather import (
+ Forecast,
+ SingleCoordinatorWeatherEntity,
+ WeatherEntityFeature,
+)
+from homeassistant.const import (
+ CONF_UNIQUE_ID,
+ UnitOfPrecipitationDepth,
+ UnitOfPressure,
+ UnitOfSpeed,
+ UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
+from .entity import IrmKmiBaseEntity
+
+
+async def async_setup_entry(
+ _hass: HomeAssistant,
+ entry: IrmKmiConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the weather entry."""
+ async_add_entities([IrmKmiWeather(entry)])
+
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+class IrmKmiWeather(
+ IrmKmiBaseEntity, # WeatherEntity
+ SingleCoordinatorWeatherEntity[IrmKmiCoordinator],
+):
+ """Weather entity for IRM KMI weather."""
+
+ _attr_name = None
+ _attr_supported_features = (
+ WeatherEntityFeature.FORECAST_DAILY
+ | WeatherEntityFeature.FORECAST_TWICE_DAILY
+ | WeatherEntityFeature.FORECAST_HOURLY
+ )
+ _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
+ _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
+ _attr_native_pressure_unit = UnitOfPressure.HPA
+
+ def __init__(self, entry: IrmKmiConfigEntry) -> None:
+ """Create a new instance of the weather entity from a configuration entry."""
+ IrmKmiBaseEntity.__init__(self, entry)
+ SingleCoordinatorWeatherEntity.__init__(self, entry.runtime_data)
+ self._attr_unique_id = entry.data[CONF_UNIQUE_ID]
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available
+
+ @property
+ def current_weather(self) -> CurrentWeatherData:
+ """Return the current weather."""
+ return self.coordinator.data.current_weather
+
+ @property
+ def condition(self) -> str | None:
+ """Return the current condition."""
+ return self.current_weather.get("condition")
+
+ @property
+ def native_temperature(self) -> float | None:
+ """Return the temperature in native units."""
+ return self.current_weather.get("temperature")
+
+ @property
+ def native_wind_speed(self) -> float | None:
+ """Return the wind speed in native units."""
+ return self.current_weather.get("wind_speed")
+
+ @property
+ def native_wind_gust_speed(self) -> float | None:
+ """Return the wind gust speed in native units."""
+ return self.current_weather.get("wind_gust_speed")
+
+ @property
+ def wind_bearing(self) -> float | str | None:
+ """Return the wind bearing."""
+ return self.current_weather.get("wind_bearing")
+
+ @property
+ def native_pressure(self) -> float | None:
+ """Return the pressure in native units."""
+ return self.current_weather.get("pressure")
+
+ @property
+ def uv_index(self) -> float | None:
+ """Return the UV index."""
+ return self.current_weather.get("uv_index")
+
+ def _async_forecast_twice_daily(self) -> list[Forecast] | None:
+ """Return the daily forecast in native units."""
+ return self.coordinator.data.daily_forecast
+
+ def _async_forecast_daily(self) -> list[Forecast] | None:
+ """Return the daily forecast in native units."""
+ return self.daily_forecast()
+
+ def _async_forecast_hourly(self) -> list[Forecast] | None:
+ """Return the hourly forecast in native units."""
+ return self.coordinator.data.hourly_forecast
+
+ def daily_forecast(self) -> list[Forecast] | None:
+ """Return the daily forecast in native units."""
+ data: list[Forecast] = self.coordinator.data.daily_forecast
+
+ # The data in daily_forecast might contain nighttime forecast.
+ # The following handle the lowest temperature attribute to be displayed correctly.
+ if (
+ len(data) > 1
+ and not data[0].get("is_daytime")
+ and data[1].get("native_templow") is None
+ ):
+ data[1]["native_templow"] = data[0].get("native_templow")
+ if (
+ data[1]["native_templow"] is not None
+ and data[1]["native_temperature"] is not None
+ and data[1]["native_templow"] > data[1]["native_temperature"]
+ ):
+ (data[1]["native_templow"], data[1]["native_temperature"]) = (
+ data[1]["native_temperature"],
+ data[1]["native_templow"],
+ )
+
+ if len(data) > 0 and not data[0].get("is_daytime"):
+ return data
+
+ if (
+ len(data) > 1
+ and data[0].get("native_templow") is None
+ and not data[1].get("is_daytime")
+ ):
+ data[0]["native_templow"] = data[1].get("native_templow")
+ if (
+ data[0]["native_templow"] is not None
+ and data[0]["native_temperature"] is not None
+ and data[0]["native_templow"] > data[0]["native_temperature"]
+ ):
+ (data[0]["native_templow"], data[0]["native_temperature"]) = (
+ data[0]["native_temperature"],
+ data[0]["native_templow"],
+ )
+
+ return [f for f in data if f.get("is_daytime")]
diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json
index be2309ab340..fb4d3fc15cd 100644
--- a/homeassistant/components/iron_os/manifest.json
+++ b/homeassistant/components/iron_os/manifest.json
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pynecil"],
"quality_scale": "platinum",
- "requirements": ["pynecil==4.1.1"]
+ "requirements": ["pynecil==4.2.0"]
}
diff --git a/homeassistant/components/isal/manifest.json b/homeassistant/components/isal/manifest.json
index 1aa5666f410..8eee5354959 100644
--- a/homeassistant/components/isal/manifest.json
+++ b/homeassistant/components/isal/manifest.json
@@ -6,5 +6,5 @@
"integration_type": "system",
"iot_class": "local_polling",
"quality_scale": "internal",
- "requirements": ["isal==1.7.1"]
+ "requirements": ["isal==1.8.0"]
}
diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json
index da983db9969..ce1a3e670a2 100644
--- a/homeassistant/components/iskra/manifest.json
+++ b/homeassistant/components/iskra/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyiskra"],
- "requirements": ["pyiskra==0.1.21"]
+ "requirements": ["pyiskra==0.1.27"]
}
diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json
index 53638ac9a29..332eb5fd3ef 100644
--- a/homeassistant/components/ista_ecotrend/manifest.json
+++ b/homeassistant/components/ista_ecotrend/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyecotrend_ista"],
"quality_scale": "gold",
- "requirements": ["pyecotrend-ista==3.3.1"]
+ "requirements": ["pyecotrend-ista==3.4.0"]
}
diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py
index 0f5a066600c..8e01b6b6ae0 100644
--- a/homeassistant/components/jewish_calendar/__init__.py
+++ b/homeassistant/components/jewish_calendar/__init__.py
@@ -29,8 +29,7 @@ from .const import (
DEFAULT_LANGUAGE,
DOMAIN,
)
-from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator
-from .entity import JewishCalendarConfigEntry
+from .entity import JewishCalendarConfigEntry, JewishCalendarData
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -70,7 +69,7 @@ async def async_setup_entry(
)
)
- data = JewishCalendarData(
+ config_entry.runtime_data = JewishCalendarData(
language,
diaspora,
location,
@@ -78,11 +77,8 @@ async def async_setup_entry(
havdalah_offset,
)
- coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data)
- await coordinator.async_config_entry_first_refresh()
-
- config_entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
return True
@@ -90,13 +86,7 @@ async def async_unload_entry(
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- ):
- coordinator = config_entry.runtime_data
- if coordinator.event_unsub:
- coordinator.event_unsub()
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def async_migrate_entry(
diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py
index 205691bc183..d5097df962f 100644
--- a/homeassistant/components/jewish_calendar/binary_sensor.py
+++ b/homeassistant/components/jewish_calendar/binary_sensor.py
@@ -72,7 +72,8 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return true if sensor is on."""
- return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now())
+ zmanim = self.make_zmanim(dt.date.today())
+ return self.entity_description.is_on(zmanim)(dt_util.now())
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
"""Return a list of times to update the sensor."""
diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py
deleted file mode 100644
index 21713313043..00000000000
--- a/homeassistant/components/jewish_calendar/coordinator.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""Data update coordinator for Jewish calendar."""
-
-from dataclasses import dataclass
-import datetime as dt
-import logging
-
-from hdate import HDateInfo, Location, Zmanim
-from hdate.translator import Language, set_language
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers import event
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-import homeassistant.util.dt as dt_util
-
-from .const import DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
-
-type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator]
-
-
-@dataclass
-class JewishCalendarData:
- """Jewish Calendar runtime dataclass."""
-
- language: Language
- diaspora: bool
- location: Location
- candle_lighting_offset: int
- havdalah_offset: int
- dateinfo: HDateInfo | None = None
- zmanim: Zmanim | None = None
-
-
-class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]):
- """Data update coordinator class for Jewish calendar."""
-
- config_entry: JewishCalendarConfigEntry
- event_unsub: CALLBACK_TYPE | None = None
-
- def __init__(
- self,
- hass: HomeAssistant,
- config_entry: JewishCalendarConfigEntry,
- data: JewishCalendarData,
- ) -> None:
- """Initialize the coordinator."""
- super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry)
- self.data = data
- self._unsub_update: CALLBACK_TYPE | None = None
- set_language(data.language)
-
- async def _async_update_data(self) -> JewishCalendarData:
- """Return HDate and Zmanim for today."""
- now = dt_util.now()
- _LOGGER.debug("Now: %s Location: %r", now, self.data.location)
-
- today = now.date()
-
- self.data.dateinfo = HDateInfo(today, self.data.diaspora)
- self.data.zmanim = self.make_zmanim(today)
- self.async_schedule_future_update()
- return self.data
-
- @callback
- def async_schedule_future_update(self) -> None:
- """Schedule the next update of the sensor for the upcoming midnight."""
- # Cancel any existing update
- if self._unsub_update:
- self._unsub_update()
- self._unsub_update = None
-
- # Calculate the next midnight
- next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1)
-
- _LOGGER.debug("Scheduling next update at %s", next_midnight)
-
- # Schedule update at next midnight
- self._unsub_update = event.async_track_point_in_time(
- self.hass, self._handle_midnight_update, next_midnight
- )
-
- @callback
- def _handle_midnight_update(self, _now: dt.datetime) -> None:
- """Handle midnight update callback."""
- self._unsub_update = None
- self.async_set_updated_data(self.data)
-
- async def async_shutdown(self) -> None:
- """Cancel any scheduled updates when the coordinator is shutting down."""
- await super().async_shutdown()
- if self._unsub_update:
- self._unsub_update()
- self._unsub_update = None
-
- def make_zmanim(self, date: dt.date) -> Zmanim:
- """Create a Zmanim object."""
- return Zmanim(
- date=date,
- location=self.data.location,
- candle_lighting_offset=self.data.candle_lighting_offset,
- havdalah_offset=self.data.havdalah_offset,
- )
-
- @property
- def zmanim(self) -> Zmanim:
- """Return the current Zmanim."""
- assert self.data.zmanim is not None, "Zmanim data not available"
- return self.data.zmanim
-
- @property
- def dateinfo(self) -> HDateInfo:
- """Return the current HDateInfo."""
- assert self.data.dateinfo is not None, "HDateInfo data not available"
- return self.data.dateinfo
diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py
index f2db0786b12..27415282b6d 100644
--- a/homeassistant/components/jewish_calendar/diagnostics.py
+++ b/homeassistant/components/jewish_calendar/diagnostics.py
@@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics(
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
- "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT),
+ "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT),
}
diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py
index d3007212739..d5e41129075 100644
--- a/homeassistant/components/jewish_calendar/entity.py
+++ b/homeassistant/components/jewish_calendar/entity.py
@@ -1,22 +1,48 @@
"""Entity representing a Jewish Calendar sensor."""
from abc import abstractmethod
+from dataclasses import dataclass
import datetime as dt
+import logging
-from hdate import Zmanim
+from hdate import HDateInfo, Location, Zmanim
+from hdate.translator import Language, set_language
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers import event
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.util import dt as dt_util
from .const import DOMAIN
-from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
-class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
+@dataclass
+class JewishCalendarDataResults:
+ """Jewish Calendar results dataclass."""
+
+ dateinfo: HDateInfo
+ zmanim: Zmanim
+
+
+@dataclass
+class JewishCalendarData:
+ """Jewish Calendar runtime dataclass."""
+
+ language: Language
+ diaspora: bool
+ location: Location
+ candle_lighting_offset: int
+ havdalah_offset: int
+ results: JewishCalendarDataResults | None = None
+
+
+class JewishCalendarEntity(Entity):
"""An HA implementation for Jewish Calendar entity."""
_attr_has_entity_name = True
@@ -29,13 +55,23 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
description: EntityDescription,
) -> None:
"""Initialize a Jewish Calendar entity."""
- super().__init__(config_entry.runtime_data)
self.entity_description = description
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)},
)
+ self.data = config_entry.runtime_data
+ set_language(self.data.language)
+
+ def make_zmanim(self, date: dt.date) -> Zmanim:
+ """Create a Zmanim object."""
+ return Zmanim(
+ date=date,
+ location=self.data.location,
+ candle_lighting_offset=self.data.candle_lighting_offset,
+ havdalah_offset=self.data.havdalah_offset,
+ )
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
@@ -56,9 +92,10 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
def _schedule_update(self) -> None:
"""Schedule the next update of the sensor."""
now = dt_util.now()
+ zmanim = self.make_zmanim(now.date())
update = dt_util.start_of_local_day() + dt.timedelta(days=1)
- for update_time in self._update_times(self.coordinator.zmanim):
+ for update_time in self._update_times(zmanim):
if update_time is not None and now < update_time < update:
update = update_time
@@ -73,4 +110,17 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
"""Update the sensor data."""
self._update_unsub = None
self._schedule_update()
+ self.create_results(now)
self.async_write_ha_state()
+
+ def create_results(self, now: dt.datetime | None = None) -> None:
+ """Create the results for the sensor."""
+ if now is None:
+ now = dt_util.now()
+
+ _LOGGER.debug("Now: %s Location: %r", now, self.data.location)
+
+ today = now.date()
+ zmanim = self.make_zmanim(today)
+ dateinfo = HDateInfo(today, diaspora=self.data.diaspora)
+ self.data.results = JewishCalendarDataResults(dateinfo, zmanim)
diff --git a/homeassistant/components/jewish_calendar/quality_scale.yaml b/homeassistant/components/jewish_calendar/quality_scale.yaml
new file mode 100644
index 00000000000..d9b77a053fc
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/quality_scale.yaml
@@ -0,0 +1,100 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: exempt
+ comment: Local calculation does not require configuration.
+ test-before-setup:
+ status: exempt
+ comment: Local calculation does not require setup.
+ unique-config-entry:
+ status: done
+ comment: >-
+ The multiple config entry was removed due to multiple bugs in the
+ integration and low ROI.
+ We might consider revisiting this as an additional feature in the future
+ to allow supportong multiple languages for states, multiple locations and maybe
+ use it as a solution for multiple Zmanim configurations.
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: This integration cannot be unavailable since it's a local calculation.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: This integration cannot be unavailable since it's a local calculation.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: This integration does not require reauthentication, since it is a local calculation.
+ test-coverage:
+ status: todo
+ comment: |-
+ The following points should be addressed:
+
+ * Don't use if-statements in tests (test_jewish_calendar_sensor, test_shabbat_times_sensor)
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: This is a local calculation and does not require discovery.
+ discovery:
+ status: exempt
+ comment: This is a local calculation and does not require discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations:
+ status: exempt
+ comment: No known limitations.
+ docs-supported-devices:
+ status: exempt
+ comment: This integration does not support physical devices.
+ docs-supported-functions: done
+ docs-troubleshooting:
+ status: exempt
+ comment: There are no more detailed troubleshooting instructions available.
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: This integration does not have physical devices.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: There are no issues that can be repaired.
+ stale-devices:
+ status: exempt
+ comment: This integration does not have physical devices.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession:
+ status: exempt
+ comment: This integration does not require a web session.
+ strict-typing: done
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
index 579c8e0f6a6..d9ad89237f5 100644
--- a/homeassistant/components/jewish_calendar/sensor.py
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-import homeassistant.util.dt as dt_util
+from homeassistant.util import dt as dt_util
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
@@ -236,18 +236,25 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
return []
return [self.entity_description.next_update_fn(zmanim)]
- def get_dateinfo(self) -> HDateInfo:
+ def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
"""Get the next date info."""
- now = dt_util.now()
+ if self.data.results is None:
+ self.create_results()
+ assert self.data.results is not None, "Results should be available"
+
+ if now is None:
+ now = dt_util.now()
+
+ today = now.date()
+ zmanim = self.make_zmanim(today)
update = None
-
if self.entity_description.next_update_fn:
- update = self.entity_description.next_update_fn(self.coordinator.zmanim)
+ update = self.entity_description.next_update_fn(zmanim)
- _LOGGER.debug("Today: %s, update: %s", now.date(), update)
+ _LOGGER.debug("Today: %s, update: %s", today, update)
if update is not None and now >= update:
- return self.coordinator.dateinfo.next_day
- return self.coordinator.dateinfo
+ return self.data.results.dateinfo.next_day
+ return self.data.results.dateinfo
class JewishCalendarSensor(JewishCalendarBaseSensor):
@@ -264,9 +271,7 @@ class JewishCalendarSensor(JewishCalendarBaseSensor):
super().__init__(config_entry, description)
# Set the options for enumeration sensors
if self.entity_description.options_fn is not None:
- self._attr_options = self.entity_description.options_fn(
- self.coordinator.data.diaspora
- )
+ self._attr_options = self.entity_description.options_fn(self.data.diaspora)
@property
def native_value(self) -> str | int | dt.datetime | None:
@@ -290,8 +295,9 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor):
@property
def native_value(self) -> dt.datetime | None:
"""Return the state of the sensor."""
+ if self.data.results is None:
+ self.create_results()
+ assert self.data.results is not None, "Results should be available"
if self.entity_description.value_fn is None:
- return self.coordinator.zmanim.zmanim[self.entity_description.key].local
- return self.entity_description.value_fn(
- self.get_dateinfo(), self.coordinator.make_zmanim
- )
+ return self.data.results.zmanim.zmanim[self.entity_description.key].local
+ return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim)
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 8b81cd49279..e6a2e98bcaf 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -32,6 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -117,6 +118,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
+) -> bool:
+ """Remove a config entry from a device."""
+
+ # Allow deleting any device except statistics_issues, just to give
+ # something to test the negative case.
+ for identifier in device_entry.identifiers:
+ if identifier[0] == DOMAIN and identifier[1] == "statistics_issues":
+ return False
+
+ return True
+
+
async def _notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py
index 6d523dda0f5..8f98089a567 100644
--- a/homeassistant/components/knx/diagnostics.py
+++ b/homeassistant/components/knx/diagnostics.py
@@ -52,8 +52,10 @@ async def async_get_config_entry_diagnostics(
try:
CONFIG_SCHEMA(raw_config)
except vol.Invalid as ex:
- diag["configuration_error"] = str(ex)
+ diag["yaml_configuration_error"] = str(ex)
else:
- diag["configuration_error"] = None
+ diag["yaml_configuration_error"] = None
+
+ diag["config_store"] = knx_module.config_store.data
return diag
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
index 1ab6883a437..bd54e5f75d9 100644
--- a/homeassistant/components/knx/light.py
+++ b/homeassistant/components/knx/light.py
@@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
group_address_switch_green_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_GREEN_SWITCH
),
- group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS),
+ group_address_brightness_green=conf.get_write(
+ CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
+ ),
group_address_brightness_green_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
),
- group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH),
- group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH),
- group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS),
+ group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH),
+ group_address_switch_blue_state=conf.get_state_and_passive(
+ CONF_COLOR, CONF_GA_BLUE_SWITCH
+ ),
+ group_address_brightness_blue=conf.get_write(
+ CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
+ ),
group_address_brightness_blue_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
),
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 508603ec66e..2f466938415 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "silver",
"requirements": [
- "xknx==3.8.0",
+ "xknx==3.9.0",
"xknxproject==3.8.2",
"knx-frontend==2025.8.24.205840"
],
diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py
index 39e627ca8ff..bc997f617b3 100644
--- a/homeassistant/components/knx/scene.py
+++ b/homeassistant/components/knx/scene.py
@@ -4,10 +4,10 @@ from __future__ import annotations
from typing import Any
-from xknx.devices import Scene as XknxScene
+from xknx.devices import Device as XknxDevice, Scene as XknxScene
from homeassistant import config_entries
-from homeassistant.components.scene import Scene
+from homeassistant.components.scene import BaseScene
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -31,7 +31,7 @@ async def async_setup_entry(
async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config)
-class KNXScene(KnxYamlEntity, Scene):
+class KNXScene(KnxYamlEntity, BaseScene):
"""Representation of a KNX scene."""
_device: XknxScene
@@ -52,6 +52,11 @@ class KNXScene(KnxYamlEntity, Scene):
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
)
- async def async_activate(self, **kwargs: Any) -> None:
+ async def _async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self._device.run()
+
+ def after_update_callback(self, device: XknxDevice) -> None:
+ """Call after device was updated."""
+ self._async_record_activation()
+ super().after_update_callback(device)
diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py
index 2e93256de47..55505fa64e5 100644
--- a/homeassistant/components/knx/storage/config_store.py
+++ b/homeassistant/components/knx/storage/config_store.py
@@ -13,11 +13,12 @@ from homeassistant.util.ulid import ulid_now
from ..const import DOMAIN
from .const import CONF_DATA
-from .migration import migrate_1_to_2
+from .migration import migrate_1_to_2, migrate_2_1_to_2_2
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION: Final = 2
+STORAGE_VERSION_MINOR: Final = 2
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"
type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
@@ -54,9 +55,13 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]):
) -> dict[str, Any]:
"""Migrate to the new version."""
if old_major_version == 1:
- # version 2 introduced in 2025.8
+ # version 2.1 introduced in 2025.8
migrate_1_to_2(old_data)
+ if old_major_version <= 2 and old_minor_version < 2:
+ # version 2.2 introduced in 2025.9.2
+ migrate_2_1_to_2_2(old_data)
+
return old_data
@@ -71,7 +76,9 @@ class KNXConfigStore:
"""Initialize config store."""
self.hass = hass
self.config_entry = config_entry
- self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY)
+ self._store = _KNXConfigStoreStorage(
+ hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
+ )
self.data = KNXConfigStoreModel(entities={})
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py
index fe0dbf31b6b..934008132a8 100644
--- a/homeassistant/components/knx/storage/entity_store_schema.py
+++ b/homeassistant/components/knx/storage/entity_store_schema.py
@@ -118,27 +118,31 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
"section_binary_control": KNXSectionFlat(),
- vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False),
+ vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"),
vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(),
"section_stop_control": KNXSectionFlat(),
- vol.Optional(CONF_GA_STOP): GASelector(state=False),
- vol.Optional(CONF_GA_STEP): GASelector(state=False),
+ vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"),
+ vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"),
"section_position_control": KNXSectionFlat(collapsible=True),
- vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False),
- vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False),
+ vol.Optional(CONF_GA_POSITION_SET): GASelector(
+ state=False, valid_dpt="5.001"
+ ),
+ vol.Optional(CONF_GA_POSITION_STATE): GASelector(
+ write=False, valid_dpt="5.001"
+ ),
vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(),
"section_tilt_control": KNXSectionFlat(collapsible=True),
- vol.Optional(CONF_GA_ANGLE): GASelector(),
+ vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"),
vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(),
"section_travel_time": KNXSectionFlat(),
- vol.Optional(
+ vol.Required(
CoverConf.TRAVELLING_TIME_UP, default=25
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=1000, step=0.1, unit_of_measurement="s"
)
),
- vol.Optional(
+ vol.Required(
CoverConf.TRAVELLING_TIME_DOWN, default=25
): selector.NumberSelector(
selector.NumberSelectorConfig(
@@ -240,19 +244,19 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
write_required=True, valid_dpt="5.001"
),
"section_blue": KNXSectionFlat(),
- vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
- write_required=True, valid_dpt="5.001"
- ),
vol.Optional(CONF_GA_BLUE_SWITCH): GASelector(
write_required=False, valid_dpt="1"
),
- "section_white": KNXSectionFlat(),
- vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
+ vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
write_required=True, valid_dpt="5.001"
),
+ "section_white": KNXSectionFlat(),
vol.Optional(CONF_GA_WHITE_SWITCH): GASelector(
write_required=False, valid_dpt="1"
),
+ vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
+ write_required=True, valid_dpt="5.001"
+ ),
},
),
GroupSelectOption(
@@ -310,7 +314,7 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
SWITCH_KNX_SCHEMA = vol.Schema(
{
"section_switch": KNXSectionFlat(),
- vol.Required(CONF_GA_SWITCH): GASelector(write_required=True),
+ vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py
index f7d7941e5cc..fbce1cc7618 100644
--- a/homeassistant/components/knx/storage/migration.py
+++ b/homeassistant/components/knx/storage/migration.py
@@ -4,6 +4,7 @@ from typing import Any
from homeassistant.const import Platform
+from ..const import CONF_RESPOND_TO_READ
from . import const as store_const
@@ -40,3 +41,12 @@ def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None:
if color:
light_knx_data[store_const.CONF_COLOR] = color
+
+
+def migrate_2_1_to_2_2(data: dict[str, Any]) -> None:
+ """Migrate from schema 2.1 to schema 2.2."""
+ if b_sensors := data.get("entities", {}).get(Platform.BINARY_SENSOR):
+ for b_sensor in b_sensors.values():
+ # "respond_to_read" was never used for binary_sensor and is not valid
+ # in the new schema. It was set as default in Store schema v1 and v2.1
+ b_sensor["knx"].pop(CONF_RESPOND_TO_READ, None)
diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py
index 0bd51f27ab6..30cffded660 100644
--- a/homeassistant/components/kodi/config_flow.py
+++ b/homeassistant/components/kodi/config_flow.py
@@ -233,28 +233,6 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN):
return self._show_ws_port_form(errors)
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Handle import from YAML."""
- reason = None
- try:
- await validate_http(self.hass, import_data)
- await validate_ws(self.hass, import_data)
- except InvalidAuth:
- _LOGGER.exception("Invalid Kodi credentials")
- reason = "invalid_auth"
- except CannotConnect:
- _LOGGER.exception("Cannot connect to Kodi")
- reason = "cannot_connect"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- reason = "unknown"
- else:
- return self.async_create_entry(
- title=import_data[CONF_NAME], data=import_data
- )
-
- return self.async_abort(reason=reason)
-
@callback
def _show_credentials_form(
self, errors: dict[str, str] | None = None
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index 2e32d969fce..1efa6bec296 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -15,7 +15,6 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
- PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
BrowseError,
BrowseMedia,
MediaPlayerEntity,
@@ -24,19 +23,11 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_ID,
- CONF_HOST,
CONF_NAME,
- CONF_PASSWORD,
- CONF_PORT,
- CONF_PROXY_SSL,
- CONF_SSL,
- CONF_TIMEOUT,
CONF_TYPE,
- CONF_USERNAME,
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.core import CoreState, HomeAssistant, callback
@@ -46,13 +37,10 @@ from homeassistant.helpers import (
entity_platform,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import (
- AddConfigEntryEntitiesCallback,
- AddEntitiesCallback,
-)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.network import is_internal_request
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
+from homeassistant.helpers.typing import VolDictType
from homeassistant.util import dt as dt_util
from . import KodiConfigEntry
@@ -62,35 +50,12 @@ from .browse_media import (
library_payload,
media_source_content_filter,
)
-from .const import (
- CONF_WS_PORT,
- DEFAULT_PORT,
- DEFAULT_SSL,
- DEFAULT_TIMEOUT,
- DEFAULT_WS_PORT,
- DOMAIN,
- EVENT_TURN_OFF,
- EVENT_TURN_ON,
-)
+from .const import DOMAIN, EVENT_TURN_OFF, EVENT_TURN_ON
_LOGGER = logging.getLogger(__name__)
EVENT_KODI_CALL_METHOD_RESULT = "kodi_call_method_result"
-CONF_TCP_PORT = "tcp_port"
-CONF_TURN_ON_ACTION = "turn_on_action"
-CONF_TURN_OFF_ACTION = "turn_off_action"
-CONF_ENABLE_WEBSOCKET = "enable_websocket"
-
-DEPRECATED_TURN_OFF_ACTIONS = {
- None: None,
- "quit": "Application.Quit",
- "hibernate": "System.Hibernate",
- "suspend": "System.Suspend",
- "reboot": "System.Reboot",
- "shutdown": "System.Shutdown",
-}
-
WEBSOCKET_WATCHDOG_INTERVAL = timedelta(seconds=10)
# https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h
@@ -120,25 +85,6 @@ MAP_KODI_MEDIA_TYPES: dict[MediaType | str, str] = {
}
-PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_TCP_PORT, default=DEFAULT_WS_PORT): cv.port,
- vol.Optional(CONF_PROXY_SSL, default=DEFAULT_SSL): cv.boolean,
- vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(
- cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)
- ),
- vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
- vol.Inclusive(CONF_USERNAME, "auth"): cv.string,
- vol.Inclusive(CONF_PASSWORD, "auth"): cv.string,
- vol.Optional(CONF_ENABLE_WEBSOCKET, default=True): cv.boolean,
- }
-)
-
-
SERVICE_ADD_MEDIA = "add_to_playlist"
SERVICE_CALL_METHOD = "call_method"
@@ -161,50 +107,6 @@ KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
)
-def find_matching_config_entries_for_host(hass, host):
- """Search existing config entries for one matching the host."""
- for entry in hass.config_entries.async_entries(DOMAIN):
- if entry.data[CONF_HOST] == host:
- return entry
- return None
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Kodi platform."""
- if discovery_info:
- # Now handled by zeroconf in the config flow
- return
-
- host = config[CONF_HOST]
- if find_matching_config_entries_for_host(hass, host):
- return
-
- websocket = config.get(CONF_ENABLE_WEBSOCKET)
- ws_port = config.get(CONF_TCP_PORT) if websocket else None
-
- entry_data = {
- CONF_NAME: config.get(CONF_NAME, host),
- CONF_HOST: host,
- CONF_PORT: config.get(CONF_PORT),
- CONF_WS_PORT: ws_port,
- CONF_USERNAME: config.get(CONF_USERNAME),
- CONF_PASSWORD: config.get(CONF_PASSWORD),
- CONF_SSL: config.get(CONF_PROXY_SSL),
- CONF_TIMEOUT: config.get(CONF_TIMEOUT),
- }
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data
- )
- )
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: KodiConfigEntry,
diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py
index dd4dbc7dbe5..42cd39d1473 100644
--- a/homeassistant/components/konnected/__init__.py
+++ b/homeassistant/components/konnected/__init__.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .config_flow import ( # Loading the config flow file will register the flow
@@ -221,6 +221,19 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Konnected platform."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_firmware",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_firmware",
+ translation_placeholders={
+ "kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
+ },
+ )
if (cfg := config.get(DOMAIN)) is None:
cfg = {}
diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json
index 7aab6fcd176..94b852476c1 100644
--- a/homeassistant/components/konnected/manifest.json
+++ b/homeassistant/components/konnected/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "konnected",
- "name": "Konnected.io",
+ "name": "Konnected.io (Legacy)",
"codeowners": ["@heythisisnate"],
"config_flow": true,
"dependencies": ["http"],
diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json
index df92e014f12..4896e4fb767 100644
--- a/homeassistant/components/konnected/strings.json
+++ b/homeassistant/components/konnected/strings.json
@@ -105,5 +105,11 @@
"abort": {
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
}
+ },
+ "issues": {
+ "deprecated_firmware": {
+ "title": "Konnected firmware is deprecated",
+ "description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant."
+ }
}
}
diff --git a/homeassistant/components/konnected_esphome/__init__.py b/homeassistant/components/konnected_esphome/__init__.py
new file mode 100644
index 00000000000..376c1b26c78
--- /dev/null
+++ b/homeassistant/components/konnected_esphome/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Konnected ESPHome."""
diff --git a/homeassistant/components/konnected_esphome/manifest.json b/homeassistant/components/konnected_esphome/manifest.json
new file mode 100644
index 00000000000..0c9827c80e6
--- /dev/null
+++ b/homeassistant/components/konnected_esphome/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "konnected_esphome",
+ "name": "Konnected",
+ "integration_type": "virtual",
+ "supported_by": "esphome"
+}
diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py
index 5c3158bddf2..ccdd704d9df 100644
--- a/homeassistant/components/kraken/__init__.py
+++ b/homeassistant/components/kraken/__init__.py
@@ -147,8 +147,9 @@ class KrakenData:
def _get_websocket_name_asset_pairs(self) -> str:
return ",".join(
- self.tradable_asset_pairs[tracked_pair]
+ pair
for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
+ if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None
)
def set_update_interval(self, update_interval: int) -> None:
diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py
index 1d3f36d29e4..2a6b36e1729 100644
--- a/homeassistant/components/kraken/sensor.py
+++ b/homeassistant/components/kraken/sensor.py
@@ -156,7 +156,7 @@ async def async_setup_entry(
for description in SENSOR_TYPES
]
)
- async_add_entities(entities, True)
+ async_add_entities(entities)
_async_add_kraken_sensors(config_entry.options[CONF_TRACKED_ASSET_PAIRS])
diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py
index 2cdf28d5e69..a5c3585eac1 100644
--- a/homeassistant/components/lacrosse/sensor.py
+++ b/homeassistant/components/lacrosse/sensor.py
@@ -152,7 +152,7 @@ class LaCrosseSensor(SensorEntity):
self._attr_name = name
lacrosse.register_callback(
- int(self._config["id"]), self._callback_lacrosse, None
+ int(self._config[CONF_ID]), self._callback_lacrosse, None
)
@property
diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py
index 92184b4ac51..2e2c8133305 100644
--- a/homeassistant/components/lamarzocco/__init__.py
+++ b/homeassistant/components/lamarzocco/__init__.py
@@ -2,7 +2,9 @@
import asyncio
import logging
+import uuid
+from aiohttp import ClientSession
from packaging import version
from pylamarzocco import (
LaMarzoccoBluetoothClient,
@@ -11,6 +13,7 @@ from pylamarzocco import (
)
from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
+from pylamarzocco.util import InstallationKey, generate_installation_key
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.const import (
@@ -19,13 +22,14 @@ from homeassistant.const import (
CONF_TOKEN,
CONF_USERNAME,
Platform,
+ __version__,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-from .const import CONF_USE_BLUETOOTH, DOMAIN
+from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoConfigEntry,
LaMarzoccoConfigUpdateCoordinator,
@@ -60,7 +64,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
- client=async_create_clientsession(hass),
+ installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]),
+ client=create_client_session(hass),
)
try:
@@ -137,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
)
coordinators = LaMarzoccoRuntimeData(
- LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
+ LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client),
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
@@ -166,45 +171,50 @@ async def async_migrate_entry(
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
) -> bool:
"""Migrate config entry."""
- if entry.version > 3:
+ if entry.version > 4:
# guard against downgrade from a future version
return False
- if entry.version == 1:
+ if entry.version in (1, 2):
_LOGGER.error(
- "Migration from version 1 is no longer supported, please remove and re-add the integration"
+ "Migration from version 1 or 2 is no longer supported, please remove and re-add the integration"
)
return False
- if entry.version == 2:
+ if entry.version == 3:
+ installation_key = generate_installation_key(str(uuid.uuid4()).lower())
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
+ installation_key=installation_key,
+ client=create_client_session(hass),
)
try:
- things = await cloud_client.list_things()
+ await cloud_client.async_register_client()
except (AuthFail, RequestNotSuccessful) as exc:
_LOGGER.error("Migration failed with error %s", exc)
return False
- v3_data = {
- CONF_USERNAME: entry.data[CONF_USERNAME],
- CONF_PASSWORD: entry.data[CONF_PASSWORD],
- CONF_TOKEN: next(
- (
- thing.ble_auth_token
- for thing in things
- if thing.serial_number == entry.unique_id
- ),
- None,
- ),
- }
- if CONF_MAC in entry.data:
- v3_data[CONF_MAC] = entry.data[CONF_MAC]
+
hass.config_entries.async_update_entry(
entry,
- data=v3_data,
- version=3,
+ data={
+ **entry.data,
+ CONF_INSTALLATION_KEY: installation_key.to_json(),
+ },
+ version=4,
)
- _LOGGER.debug("Migrated La Marzocco config entry to version 2")
+ _LOGGER.debug("Migrated La Marzocco config entry to version 4")
return True
+
+
+def create_client_session(hass: HomeAssistant) -> ClientSession:
+ """Create a ClientSession with La Marzocco specific headers."""
+
+ return async_create_clientsession(
+ hass,
+ headers={
+ "X-Client": "HOME_ASSISTANT",
+ "X-Client-Build": __version__,
+ },
+ )
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index fb968a0b4af..ab99fbbc63f 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -5,11 +5,13 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
+import uuid
from aiohttp import ClientSession
from pylamarzocco import LaMarzoccoCloudClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import Thing
+from pylamarzocco.util import InstallationKey, generate_installation_key
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -33,7 +35,6 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -45,7 +46,8 @@ from homeassistant.helpers.selector import (
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import CONF_USE_BLUETOOTH, DOMAIN
+from . import create_client_session
+from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
@@ -57,9 +59,10 @@ _LOGGER = logging.getLogger(__name__)
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for La Marzocco."""
- VERSION = 3
+ VERSION = 4
_client: ClientSession
+ _installation_key: InstallationKey
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -83,13 +86,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
}
- self._client = async_create_clientsession(self.hass)
+ self._client = create_client_session(self.hass)
+ self._installation_key = generate_installation_key(
+ str(uuid.uuid4()).lower()
+ )
cloud_client = LaMarzoccoCloudClient(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
client=self._client,
+ installation_key=self._installation_key,
)
try:
+ await cloud_client.async_register_client()
things = await cloud_client.list_things()
except AuthFail:
_LOGGER.debug("Server rejected login credentials")
@@ -184,6 +192,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
title=selected_device.name,
data={
**self._config,
+ CONF_INSTALLATION_KEY: self._installation_key.to_json(),
CONF_TOKEN: self._things[serial_number].ble_auth_token,
},
)
diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py
index 57db84f94da..680557d85f1 100644
--- a/homeassistant/components/lamarzocco/const.py
+++ b/homeassistant/components/lamarzocco/const.py
@@ -5,3 +5,4 @@ from typing import Final
DOMAIN: Final = "lamarzocco"
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
+CONF_INSTALLATION_KEY: Final = "installation_key"
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index b6379f237ae..b5fa0ed9028 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -8,7 +8,7 @@ from datetime import timedelta
import logging
from typing import Any
-from pylamarzocco import LaMarzoccoMachine
+from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN
-SCAN_INTERVAL = timedelta(seconds=15)
+SCAN_INTERVAL = timedelta(seconds=60)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=8)
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30)
STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15)
@@ -51,6 +51,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
device: LaMarzoccoMachine,
+ cloud_client: LaMarzoccoCloudClient | None = None,
) -> None:
"""Initialize coordinator."""
super().__init__(
@@ -61,6 +62,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
update_interval=self._default_update_interval,
)
self.device = device
+ self.cloud_client = cloud_client
async def _async_update_data(self) -> None:
"""Do the data update."""
@@ -85,11 +87,17 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally."""
+ cloud_client: LaMarzoccoCloudClient
+
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
+ # ensure token stays valid; does nothing if token is still valid
+ await self.cloud_client.async_get_access_token()
+
if self.device.websocket.connected:
return
+
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json
index 3c070769b5b..3bf47df83a4 100644
--- a/homeassistant/components/lamarzocco/manifest.json
+++ b/homeassistant/components/lamarzocco/manifest.json
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
- "requirements": ["pylamarzocco==2.0.11"]
+ "requirements": ["pylamarzocco==2.1.1"]
}
diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py
index 1f4983a03a8..2e36db85fc4 100644
--- a/homeassistant/components/lamarzocco/sensor.py
+++ b/homeassistant/components/lamarzocco/sensor.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from datetime import datetime
from typing import cast
-from pylamarzocco.const import BackFlushStatus, ModelName, WidgetType
+from pylamarzocco.const import BackFlushStatus, MachineState, ModelName, WidgetType
from pylamarzocco.models import (
BackFlush,
BaseWidgetOutput,
@@ -97,7 +97,14 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
).brewing_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
- available_fn=(lambda coordinator: not coordinator.websocket_terminated),
+ available_fn=(
+ lambda coordinator: not coordinator.websocket_terminated
+ and cast(
+ MachineStatus,
+ coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS],
+ ).status
+ is MachineState.BREWING
+ ),
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py
index fe486660438..983a5e7b32a 100644
--- a/homeassistant/components/lannouncer/notify.py
+++ b/homeassistant/components/lannouncer/notify.py
@@ -14,10 +14,12 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+DOMAIN = "lannouncer"
+
ATTR_METHOD = "method"
ATTR_METHOD_DEFAULT = "speak"
ATTR_METHOD_ALLOWED = ["speak", "alarm"]
@@ -40,6 +42,22 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> LannouncerNotificationService:
"""Get the Lannouncer notification service."""
+
+ @callback
+ def _async_create_issue() -> None:
+ """Create issue for removed integration."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "integration_removed",
+ is_fixable=False,
+ breaks_in_ha_version="2026.3.0",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="integration_removed",
+ )
+
+ hass.add_job(_async_create_issue)
+
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
diff --git a/homeassistant/components/lannouncer/strings.json b/homeassistant/components/lannouncer/strings.json
new file mode 100644
index 00000000000..7ce8a542fe5
--- /dev/null
+++ b/homeassistant/components/lannouncer/strings.json
@@ -0,0 +1,8 @@
+{
+ "issues": {
+ "integration_removed": {
+ "title": "LANnouncer integration is deprecated",
+ "description": "The LANnouncer Android app is no longer available, so this integration has been deprecated and will be removed in a future release.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear."
+ }
+ }
+}
diff --git a/homeassistant/components/lawn_mower/intent.py b/homeassistant/components/lawn_mower/intent.py
new file mode 100644
index 00000000000..a0176446b77
--- /dev/null
+++ b/homeassistant/components/lawn_mower/intent.py
@@ -0,0 +1,37 @@
+"""Intents for the lawn mower integration."""
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import intent
+
+from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerEntityFeature
+
+INTENT_LANW_MOWER_START_MOWING = "HassLawnMowerStartMowing"
+INTENT_LANW_MOWER_DOCK = "HassLawnMowerDock"
+
+
+async def async_setup_intents(hass: HomeAssistant) -> None:
+ """Set up the lawn mower intents."""
+ intent.async_register(
+ hass,
+ intent.ServiceIntentHandler(
+ INTENT_LANW_MOWER_START_MOWING,
+ DOMAIN,
+ SERVICE_START_MOWING,
+ description="Starts a lawn mower",
+ required_domains={DOMAIN},
+ platforms={DOMAIN},
+ required_features=LawnMowerEntityFeature.START_MOWING,
+ ),
+ )
+ intent.async_register(
+ hass,
+ intent.ServiceIntentHandler(
+ INTENT_LANW_MOWER_DOCK,
+ DOMAIN,
+ SERVICE_DOCK,
+ description="Sends a lawn mower to dock",
+ required_domains={DOMAIN},
+ platforms={DOMAIN},
+ required_features=LawnMowerEntityFeature.DOCK,
+ ),
+ )
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index 78acba31afd..a08ee0d8880 100644
--- a/homeassistant/components/lcn/manifest.json
+++ b/homeassistant/components/lcn/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["pypck"],
"quality_scale": "bronze",
- "requirements": ["pypck==0.8.10", "lcn-frontend==0.2.7"]
+ "requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"]
}
diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json
index 90d4bdcd4ad..7a8afe10105 100644
--- a/homeassistant/components/lcn/strings.json
+++ b/homeassistant/components/lcn/strings.json
@@ -92,10 +92,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "The device ID of the LCN module or group."
},
- "address": {
- "name": "Address",
- "description": "Module address."
- },
"output": {
"name": "Output",
"description": "Output port."
@@ -118,10 +114,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"output": {
"name": "[%key:component::lcn::services::output_abs::fields::output::name%]",
"description": "[%key:component::lcn::services::output_abs::fields::output::description%]"
@@ -140,10 +132,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"output": {
"name": "[%key:component::lcn::services::output_abs::fields::output::name%]",
"description": "[%key:component::lcn::services::output_abs::fields::output::description%]"
@@ -162,10 +150,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"state": {
"name": "State",
"description": "Relay states as string (1=on, 2=off, t=toggle, -=no change)."
@@ -180,10 +164,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"led": {
"name": "[%key:component::lcn::services::led::name%]",
"description": "The LED port of the device."
@@ -202,10 +182,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"variable": {
"name": "Variable",
"description": "Variable or setpoint name."
@@ -228,10 +204,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"variable": {
"name": "[%key:component::lcn::services::var_abs::fields::variable::name%]",
"description": "[%key:component::lcn::services::var_abs::fields::variable::description%]"
@@ -246,10 +218,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"variable": {
"name": "[%key:component::lcn::services::var_abs::fields::variable::name%]",
"description": "[%key:component::lcn::services::var_abs::fields::variable::description%]"
@@ -276,10 +244,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"setpoint": {
"name": "Setpoint",
"description": "Setpoint name."
@@ -298,10 +262,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"keys": {
"name": "Keys",
"description": "Keys to send."
@@ -328,10 +288,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"table": {
"name": "Table",
"description": "Table with keys to lock (must be A for interval)."
@@ -358,10 +314,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"row": {
"name": "Row",
"description": "Text row."
@@ -380,10 +332,6 @@
"name": "[%key:common::config_flow::data::device%]",
"description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]"
},
- "address": {
- "name": "Address",
- "description": "[%key:component::lcn::services::output_abs::fields::address::description%]"
- },
"pck": {
"name": "[%key:component::lcn::services::pck::name%]",
"description": "PCK command (without address header)."
diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json
index 1efe4e05682..016377154d2 100644
--- a/homeassistant/components/ld2410_ble/manifest.json
+++ b/homeassistant/components/ld2410_ble/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
+ "requirements": ["bluetooth-data-tools==1.28.3", "ld2410-ble==0.1.1"]
}
diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json
index 3a73c28cdf6..7871be1c552 100644
--- a/homeassistant/components/led_ble/manifest.json
+++ b/homeassistant/components/led_ble/manifest.json
@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
- "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
+ "requirements": ["bluetooth-data-tools==1.28.3", "led-ble==1.1.7"]
}
diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py
index 7bcb04b2b4d..7e168792887 100644
--- a/homeassistant/components/letpot/__init__.py
+++ b/homeassistant/components/letpot/__init__.py
@@ -25,6 +25,7 @@ from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
+ Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json
index 1f5e79b04dd..aac6326d077 100644
--- a/homeassistant/components/letpot/icons.json
+++ b/homeassistant/components/letpot/icons.json
@@ -20,6 +20,14 @@
}
}
},
+ "number": {
+ "light_brightness": {
+ "default": "mdi:brightness-5"
+ },
+ "plant_days": {
+ "default": "mdi:calendar-blank"
+ }
+ },
"select": {
"display_temperature_unit": {
"default": "mdi:thermometer-lines"
diff --git a/homeassistant/components/letpot/number.py b/homeassistant/components/letpot/number.py
new file mode 100644
index 00000000000..2061b419ddb
--- /dev/null
+++ b/homeassistant/components/letpot/number.py
@@ -0,0 +1,137 @@
+"""Support for LetPot number entities."""
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any
+
+from letpot.deviceclient import LetPotDeviceClient
+from letpot.models import DeviceFeature
+
+from homeassistant.components.number import (
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.const import PRECISION_WHOLE, EntityCategory, UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
+from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
+
+# Each change pushes a 'full' device status with the change. The library will cache
+# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
+PARALLEL_UPDATES = 1
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription):
+ """Describes a LetPot number entity."""
+
+ max_value_fn: Callable[[LetPotDeviceCoordinator], float]
+ value_fn: Callable[[LetPotDeviceCoordinator], float | None]
+ set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]]
+
+
+NUMBERS: tuple[LetPotNumberEntityDescription, ...] = (
+ LetPotNumberEntityDescription(
+ key="light_brightness_levels",
+ translation_key="light_brightness",
+ value_fn=(
+ lambda coordinator: coordinator.device_client.get_light_brightness_levels(
+ coordinator.device.serial_number
+ ).index(coordinator.data.light_brightness)
+ + 1
+ if coordinator.data.light_brightness is not None
+ else None
+ ),
+ set_value_fn=(
+ lambda device_client, serial, value: device_client.set_light_brightness(
+ serial,
+ device_client.get_light_brightness_levels(serial)[int(value) - 1],
+ )
+ ),
+ supported_fn=(
+ lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
+ in coordinator.device_client.device_info(
+ coordinator.device.serial_number
+ ).features
+ ),
+ native_min_value=float(1),
+ max_value_fn=lambda coordinator: float(
+ len(
+ coordinator.device_client.get_light_brightness_levels(
+ coordinator.device.serial_number
+ )
+ )
+ ),
+ native_step=PRECISION_WHOLE,
+ mode=NumberMode.SLIDER,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ LetPotNumberEntityDescription(
+ key="plant_days",
+ translation_key="plant_days",
+ native_unit_of_measurement=UnitOfTime.DAYS,
+ value_fn=lambda coordinator: coordinator.data.plant_days,
+ set_value_fn=(
+ lambda device_client, serial, value: device_client.set_plant_days(
+ serial, int(value)
+ )
+ ),
+ native_min_value=float(0),
+ max_value_fn=lambda _: float(999),
+ native_step=PRECISION_WHOLE,
+ mode=NumberMode.BOX,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up LetPot number entities based on a config entry and device status/features."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotNumberEntity(coordinator, description)
+ for description in NUMBERS
+ for coordinator in coordinators
+ if description.supported_fn(coordinator)
+ )
+
+
+class LetPotNumberEntity(LetPotEntity, NumberEntity):
+ """Defines a LetPot number entity."""
+
+ entity_description: LetPotNumberEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotNumberEntityDescription,
+ ) -> None:
+ """Initialize LetPot number entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def native_max_value(self) -> float:
+ """Return the maximum available value."""
+ return self.entity_description.max_value_fn(self.coordinator)
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the number value."""
+ return self.entity_description.value_fn(self.coordinator)
+
+ @exception_handler
+ async def async_set_native_value(self, value: float) -> None:
+ """Change the number value."""
+ return await self.entity_description.set_value_fn(
+ self.coordinator.device_client,
+ self.coordinator.device.serial_number,
+ value,
+ )
diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json
index 6ebd79edf5d..3af8c7e3db6 100644
--- a/homeassistant/components/letpot/strings.json
+++ b/homeassistant/components/letpot/strings.json
@@ -49,6 +49,14 @@
"name": "Refill error"
}
},
+ "number": {
+ "light_brightness": {
+ "name": "Light brightness"
+ },
+ "plant_days": {
+ "name": "Plants age"
+ }
+ },
"select": {
"display_temperature_unit": {
"name": "Temperature unit on display",
@@ -58,7 +66,7 @@
}
},
"light_brightness": {
- "name": "Light brightness",
+ "name": "[%key:component::letpot::entity::number::light_brightness::name%]",
"state": {
"low": "[%key:common::state::low%]",
"high": "[%key:common::state::high%]"
diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py
index ffdde3188db..0a51b856131 100644
--- a/homeassistant/components/lg_thinq/coordinator.py
+++ b/homeassistant/components/lg_thinq/coordinator.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+from datetime import time
import logging
from typing import TYPE_CHECKING, Any
@@ -70,6 +71,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
event_filter=self.async_config_update_filter,
)
)
+ # Time of day for fetching the device's energy usage
+ # (randomly assigned when device is first created in Home Assistant)
+ self.update_energy_at_time_of_day: time | None = None
async def _handle_update_config(self, _: Event) -> None:
"""Handle update core config."""
diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py
index 61d8199f321..3c41b3e8fac 100644
--- a/homeassistant/components/lg_thinq/entity.py
+++ b/homeassistant/components/lg_thinq/entity.py
@@ -34,6 +34,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
coordinator: DeviceDataUpdateCoordinator,
entity_description: EntityDescription,
property_id: str,
+ postfix_id: str | None = None,
) -> None:
"""Initialize an entity."""
super().__init__(coordinator)
@@ -48,7 +49,11 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})",
name=coordinator.device_name,
)
- self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}"
+ self._attr_unique_id = (
+ f"{coordinator.unique_id}_{self.property_id}"
+ if postfix_id is None
+ else f"{coordinator.unique_id}_{self.property_id}_{postfix_id}"
+ )
if self.location is not None and self.location not in (
Location.MAIN,
Location.OVEN,
diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json
index f7001a92b9d..527480a9065 100644
--- a/homeassistant/components/lg_thinq/icons.json
+++ b/homeassistant/components/lg_thinq/icons.json
@@ -282,9 +282,24 @@
"filter_lifetime": {
"default": "mdi:air-filter"
},
+ "top_filter_remain_percent": {
+ "default": "mdi:air-filter"
+ },
"used_time": {
"default": "mdi:air-filter"
},
+ "water_filter_state": {
+ "default": "mdi:air-filter"
+ },
+ "water_filter_1_remain_percent": {
+ "default": "mdi:air-filter"
+ },
+ "water_filter_2_remain_percent": {
+ "default": "mdi:air-filter"
+ },
+ "water_filter_3_remain_percent": {
+ "default": "mdi:air-filter"
+ },
"current_job_mode": {
"default": "mdi:dots-circle"
},
@@ -440,6 +455,15 @@
},
"cycle_count_for_location": {
"default": "mdi:counter"
+ },
+ "energy_usage_yesterday": {
+ "default": "mdi:chart-bar"
+ },
+ "energy_usage_this_month": {
+ "default": "mdi:chart-bar"
+ },
+ "energy_usage_last_month": {
+ "default": "mdi:chart-bar"
}
}
}
diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json
index 0abc74d19a4..c1e620b1f86 100644
--- a/homeassistant/components/lg_thinq/manifest.json
+++ b/homeassistant/components/lg_thinq/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
- "requirements": ["thinqconnect==1.0.7"]
+ "requirements": ["thinqconnect==1.0.8"]
}
diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py
index 44dfd251dc6..578611952ba 100644
--- a/homeassistant/components/lg_thinq/sensor.py
+++ b/homeassistant/components/lg_thinq/sensor.py
@@ -2,10 +2,13 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
from datetime import datetime, time, timedelta
import logging
+import random
-from thinqconnect import DeviceType
+from thinqconnect import USAGE_DAILY, USAGE_MONTHLY, DeviceType, ThinQAPIException
from thinqconnect.devices.const import Property as ThinQProperty
from thinqconnect.integration import ActiveMode, ThinQPropertyEx, TimerProperty
@@ -18,11 +21,13 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
+ UnitOfEnergy,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.util import dt as dt_util
from . import ThinqConfigEntry
@@ -105,6 +110,11 @@ FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
translation_key=ThinQProperty.FILTER_LIFETIME,
),
+ ThinQProperty.TOP_FILTER_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT,
+ ),
}
HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription(
@@ -216,6 +226,11 @@ REFRIGERATION_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
device_class=SensorDeviceClass.ENUM,
translation_key=ThinQProperty.FRESH_AIR_FILTER,
),
+ ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.FRESH_AIR_FILTER,
+ ),
}
RUN_STATE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.CURRENT_STATE: SensorEntityDescription(
@@ -298,6 +313,25 @@ WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
native_unit_of_measurement=UnitOfTime.MONTHS,
translation_key=ThinQProperty.USED_TIME,
),
+ ThinQProperty.WATER_FILTER_STATE: SensorEntityDescription(
+ key=ThinQProperty.WATER_FILTER_STATE,
+ translation_key=ThinQProperty.WATER_FILTER_STATE,
+ ),
+ ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT,
+ ),
+ ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT,
+ ),
+ ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT: SensorEntityDescription(
+ key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT,
+ native_unit_of_measurement=PERCENTAGE,
+ translation_key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT,
+ ),
}
WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.WATER_TYPE: SensorEntityDescription(
@@ -432,6 +466,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT],
+ FILTER_INFO_SENSOR_DESC[ThinQProperty.TOP_FILTER_REMAIN_PERCENT],
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE],
TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
@@ -508,7 +543,12 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
),
DeviceType.REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
+ REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT],
WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.USED_TIME],
+ WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_STATE],
+ WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT],
+ WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT],
+ WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT],
),
DeviceType.ROBOT_CLEANER: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
@@ -553,6 +593,44 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
),
}
+
+@dataclass(frozen=True, kw_only=True)
+class ThinQEnergySensorEntityDescription(SensorEntityDescription):
+ """Describes ThinQ energy sensor entity."""
+
+ device_class = SensorDeviceClass.ENERGY
+ state_class = SensorStateClass.TOTAL
+ native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
+ suggested_display_precision = 0
+ usage_period: str
+ start_date_fn: Callable[[datetime], datetime]
+ end_date_fn: Callable[[datetime], datetime]
+ update_interval: timedelta = timedelta(days=1)
+
+
+ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
+ ThinQEnergySensorEntityDescription(
+ key="yesterday",
+ translation_key="energy_usage_yesterday",
+ usage_period=USAGE_DAILY,
+ start_date_fn=lambda today: today - timedelta(days=1),
+ end_date_fn=lambda today: today - timedelta(days=1),
+ ),
+ ThinQEnergySensorEntityDescription(
+ key="this_month",
+ translation_key="energy_usage_this_month",
+ usage_period=USAGE_MONTHLY,
+ start_date_fn=lambda today: today,
+ end_date_fn=lambda today: today,
+ ),
+ ThinQEnergySensorEntityDescription(
+ key="last_month",
+ translation_key="energy_usage_last_month",
+ usage_period=USAGE_MONTHLY,
+ start_date_fn=lambda today: today.replace(day=1) - timedelta(days=1),
+ end_date_fn=lambda today: today.replace(day=1) - timedelta(days=1),
+ ),
+)
_LOGGER = logging.getLogger(__name__)
@@ -562,7 +640,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for sensor platform."""
- entities: list[ThinQSensorEntity] = []
+ entities: list[ThinQSensorEntity | ThinQEnergySensorEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_SENSOR_MAP.get(
@@ -584,7 +662,23 @@ async def async_setup_entry(
),
)
)
-
+ for energy_description in ENERGY_USAGE_SENSORS:
+ entities.extend(
+ ThinQEnergySensorEntity(
+ coordinator=coordinator,
+ entity_description=energy_description,
+ property_id=energy_property_id,
+ postfix_id=energy_description.key,
+ )
+ for energy_property_id in coordinator.api.get_active_idx(
+ (
+ ThinQPropertyEx.ENERGY_USAGE
+ if coordinator.sub_id is None
+ else f"{ThinQPropertyEx.ENERGY_USAGE}_{coordinator.sub_id}"
+ ),
+ ActiveMode.READ_ONLY,
+ )
+ )
if entities:
async_add_entities(entities)
@@ -686,3 +780,84 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
if unit == UnitOfTime.SECONDS:
return (data.hour * 3600) + (data.minute * 60) + data.second
return 0
+
+
+class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
+ """Represent a ThinQ energy sensor platform."""
+
+ entity_description: ThinQEnergySensorEntityDescription
+ _stop_update: Callable[[], None] | None = None
+
+ async def async_added_to_hass(self) -> None:
+ """Handle added to Hass."""
+ await super().async_added_to_hass()
+ if self.coordinator.update_energy_at_time_of_day is None:
+ # random time 01:00:00 ~ 02:59:00
+ self.coordinator.update_energy_at_time_of_day = time(
+ hour=random.randint(1, 2), minute=random.randint(0, 59)
+ )
+ _LOGGER.debug(
+ "[%s] Set energy update time: %s",
+ self.coordinator.device_name,
+ self.coordinator.update_energy_at_time_of_day,
+ )
+
+ await self._async_update_and_schedule()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ if self._stop_update is not None:
+ self._stop_update()
+ return await super().async_will_remove_from_hass()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available or self.native_value is not None
+
+ async def async_update(self, now: datetime | None = None) -> None:
+ """Update the state of the sensor."""
+ await self._async_update_and_schedule()
+ self.async_write_ha_state()
+
+ async def _async_update_and_schedule(self) -> None:
+ """Update the state of the sensor."""
+ local_now = datetime.now(
+ dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
+ )
+ next_update = local_now + self.entity_description.update_interval
+ if self.coordinator.update_energy_at_time_of_day is not None:
+ # calculate next_update time by combining tomorrow and update_energy_at_time_of_day
+ next_update = datetime.combine(
+ (next_update).date(),
+ self.coordinator.update_energy_at_time_of_day,
+ next_update.tzinfo,
+ )
+ try:
+ self._attr_native_value = await self.coordinator.api.async_get_energy_usage(
+ energy_property=self.property_id,
+ period=self.entity_description.usage_period,
+ start_date=(self.entity_description.start_date_fn(local_now)).date(),
+ end_date=(self.entity_description.end_date_fn(local_now)).date(),
+ detail=False,
+ )
+ except ThinQAPIException as exc:
+ _LOGGER.warning(
+ "[%s:%s] Failed to fetch energy usage data. reason=%s",
+ self.coordinator.device_name,
+ self.entity_description.key,
+ exc,
+ )
+ finally:
+ _LOGGER.debug(
+ "[%s:%s] async_update_and_schedule next_update: %s, native_value: %s",
+ self.coordinator.device_name,
+ self.entity_description.key,
+ next_update,
+ self._attr_native_value,
+ )
+ self._stop_update = async_track_point_in_time(
+ self.coordinator.hass,
+ self.async_update,
+ next_update,
+ )
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index 52b9ea4a346..bb90b668d4e 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -241,7 +241,9 @@
"timer_is_complete": "Timer has been completed",
"washing_is_complete": "Washing is completed",
"water_is_full": "Water is full",
- "water_leak_has_occurred": "The dishwasher has detected a water leak"
+ "water_leak_has_occurred": "The dishwasher has detected a water leak",
+ "filter_reset_complete": "The filter lifetime has been reset",
+ "water_filter_reset_complete": "The water filter lifetime has been reset"
}
}
}
@@ -608,9 +610,24 @@
"filter_lifetime": {
"name": "Filter remaining"
},
+ "top_filter_remain_percent": {
+ "name": "Upper filter remaining"
+ },
"used_time": {
"name": "Water filter used"
},
+ "water_filter_state": {
+ "name": "Water filter"
+ },
+ "water_filter_1_remain_percent": {
+ "name": "[%key:component::lg_thinq::entity::sensor::water_filter_state::name%]"
+ },
+ "water_filter_2_remain_percent": {
+ "name": "Water filter stage 2"
+ },
+ "water_filter_3_remain_percent": {
+ "name": "Water filter stage 3"
+ },
"current_job_mode": {
"name": "Operating mode",
"state": {
@@ -923,6 +940,15 @@
},
"cycle_count_for_location": {
"name": "{location} cycles"
+ },
+ "energy_usage_yesterday": {
+ "name": "Energy yesterday"
+ },
+ "energy_usage_this_month": {
+ "name": "Energy this month"
+ },
+ "energy_usage_last_month": {
+ "name": "Energy last month"
}
},
"select": {
diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py
new file mode 100644
index 00000000000..4f39d51e963
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/__init__.py
@@ -0,0 +1,34 @@
+"""The LibreHardwareMonitor integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import (
+ LibreHardwareMonitorConfigEntry,
+ LibreHardwareMonitorCoordinator,
+)
+
+PLATFORMS = [Platform.SENSOR]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
+) -> bool:
+ """Set up LibreHardwareMonitor from a config entry."""
+
+ lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry)
+ await lhm_coordinator.async_config_entry_first_refresh()
+
+ config_entry.runtime_data = lhm_coordinator
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/libre_hardware_monitor/config_flow.py b/homeassistant/components/libre_hardware_monitor/config_flow.py
new file mode 100644
index 00000000000..f24c801254c
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/config_flow.py
@@ -0,0 +1,63 @@
+"""Config flow for LibreHardwareMonitor."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from librehardwaremonitor_api import (
+ LibreHardwareMonitorClient,
+ LibreHardwareMonitorConnectionError,
+ LibreHardwareMonitorNoDevicesError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ }
+)
+
+
+class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for LibreHardwareMonitor."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match(user_input)
+
+ api = LibreHardwareMonitorClient(
+ user_input[CONF_HOST], user_input[CONF_PORT]
+ )
+
+ try:
+ _ = (await api.get_data()).main_device_ids_and_names.values()
+ except LibreHardwareMonitorConnectionError as exception:
+ _LOGGER.error(exception)
+ errors["base"] = "cannot_connect"
+ except LibreHardwareMonitorNoDevicesError:
+ errors["base"] = "no_devices"
+ else:
+ return self.async_create_entry(
+ title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/libre_hardware_monitor/const.py b/homeassistant/components/libre_hardware_monitor/const.py
new file mode 100644
index 00000000000..88380a6cf9d
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/const.py
@@ -0,0 +1,6 @@
+"""Constants for the LibreHardwareMonitor integration."""
+
+DOMAIN = "libre_hardware_monitor"
+DEFAULT_HOST = "localhost"
+DEFAULT_PORT = 8085
+DEFAULT_SCAN_INTERVAL = 10
diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py
new file mode 100644
index 00000000000..6e87fd70301
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/coordinator.py
@@ -0,0 +1,130 @@
+"""Coordinator for LibreHardwareMonitor integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from types import MappingProxyType
+
+from librehardwaremonitor_api import (
+ LibreHardwareMonitorClient,
+ LibreHardwareMonitorConnectionError,
+ LibreHardwareMonitorNoDevicesError,
+)
+from librehardwaremonitor_api.model import (
+ DeviceId,
+ DeviceName,
+ LibreHardwareMonitorData,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceEntry
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type LibreHardwareMonitorConfigEntry = ConfigEntry[LibreHardwareMonitorCoordinator]
+
+
+class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitorData]):
+ """Class to manage fetching LibreHardwareMonitor data."""
+
+ config_entry: LibreHardwareMonitorConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry
+ ) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ config_entry=config_entry,
+ update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
+ )
+
+ host = config_entry.data[CONF_HOST]
+ port = config_entry.data[CONF_PORT]
+ self._api = LibreHardwareMonitorClient(host, port)
+ device_entries: list[DeviceEntry] = dr.async_entries_for_config_entry(
+ registry=dr.async_get(self.hass), config_entry_id=config_entry.entry_id
+ )
+ self._previous_devices: MappingProxyType[DeviceId, DeviceName] = (
+ MappingProxyType(
+ {
+ DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
+ for device in device_entries
+ if device.identifiers and device.name
+ }
+ )
+ )
+
+ async def _async_update_data(self) -> LibreHardwareMonitorData:
+ try:
+ lhm_data = await self._api.get_data()
+ except LibreHardwareMonitorConnectionError as err:
+ raise UpdateFailed(
+ "LibreHardwareMonitor connection failed, will retry"
+ ) from err
+ except LibreHardwareMonitorNoDevicesError as err:
+ raise UpdateFailed("No sensor data available, will retry") from err
+
+ await self._async_handle_changes_in_devices(lhm_data.main_device_ids_and_names)
+
+ return lhm_data
+
+ async def _async_refresh(
+ self,
+ log_failures: bool = True,
+ raise_on_auth_failed: bool = False,
+ scheduled: bool = False,
+ raise_on_entry_error: bool = False,
+ ) -> None:
+ # we don't expect the computer to be online 24/7 so we don't want to log a connection loss as an error
+ await super()._async_refresh(
+ False, raise_on_auth_failed, scheduled, raise_on_entry_error
+ )
+
+ async def _async_handle_changes_in_devices(
+ self, detected_devices: MappingProxyType[DeviceId, DeviceName]
+ ) -> None:
+ """Handle device changes by deleting devices from / adding devices to Home Assistant."""
+ previous_device_ids = set(self._previous_devices.keys())
+ detected_device_ids = set(detected_devices.keys())
+
+ if previous_device_ids == detected_device_ids:
+ return
+
+ if self.data is None:
+ # initial update during integration startup
+ self._previous_devices = detected_devices # type: ignore[unreachable]
+ return
+
+ if orphaned_devices := previous_device_ids - detected_device_ids:
+ _LOGGER.warning(
+ "Device(s) no longer available, will be removed: %s",
+ [self._previous_devices[device_id] for device_id in orphaned_devices],
+ )
+ device_registry = dr.async_get(self.hass)
+ for device_id in orphaned_devices:
+ if device := device_registry.async_get_device(
+ identifiers={(DOMAIN, device_id)}
+ ):
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+
+ if new_devices := detected_device_ids - previous_device_ids:
+ _LOGGER.warning(
+ "New Device(s) detected, reload integration to add them to Home Assistant: %s",
+ [detected_devices[DeviceId(device_id)] for device_id in new_devices],
+ )
+
+ self._previous_devices = detected_devices
diff --git a/homeassistant/components/libre_hardware_monitor/manifest.json b/homeassistant/components/libre_hardware_monitor/manifest.json
new file mode 100644
index 00000000000..322f3f2934f
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "libre_hardware_monitor",
+ "name": "Libre Hardware Monitor",
+ "codeowners": ["@Sab44"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
+ "iot_class": "local_polling",
+ "quality_scale": "silver",
+ "requirements": ["librehardwaremonitor-api==1.4.0"]
+}
diff --git a/homeassistant/components/libre_hardware_monitor/quality_scale.yaml b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml
new file mode 100644
index 00000000000..cdb13882038
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml
@@ -0,0 +1,81 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: exempt
+ comment: |
+ Device is expected to be offline most of the time, but needs to connect quickly once available.
+ unique-config-entry: done
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: |
+ Device is expected to be temporarily unavailable.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices: done
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: done
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/libre_hardware_monitor/sensor.py b/homeassistant/components/libre_hardware_monitor/sensor.py
new file mode 100644
index 00000000000..cb7d94ae73e
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/sensor.py
@@ -0,0 +1,95 @@
+"""Support for LibreHardwareMonitor Sensor Platform."""
+
+from __future__ import annotations
+
+from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
+
+from homeassistant.components.sensor import SensorEntity, SensorStateClass
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import LibreHardwareMonitorCoordinator
+from .const import DOMAIN
+from .coordinator import LibreHardwareMonitorConfigEntry
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+STATE_MIN_VALUE = "min_value"
+STATE_MAX_VALUE = "max_value"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LibreHardwareMonitorConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the LibreHardwareMonitor platform."""
+ lhm_coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ LibreHardwareMonitorSensor(lhm_coordinator, sensor_data)
+ for sensor_data in lhm_coordinator.data.sensor_data.values()
+ )
+
+
+class LibreHardwareMonitorSensor(
+ CoordinatorEntity[LibreHardwareMonitorCoordinator], SensorEntity
+):
+ """Sensor to display information from LibreHardwareMonitor."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: LibreHardwareMonitorCoordinator,
+ sensor_data: LibreHardwareMonitorSensorData,
+ ) -> None:
+ """Initialize an LibreHardwareMonitor sensor."""
+ super().__init__(coordinator)
+
+ self._attr_name: str = sensor_data.name
+ self.value: str | None = sensor_data.value
+ self._attr_extra_state_attributes: dict[str, str] = {
+ STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
+ STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
+ }
+ self._attr_native_unit_of_measurement = sensor_data.unit
+ self._attr_unique_id: str = f"lhm-{sensor_data.sensor_id}"
+
+ self._sensor_id: str = sensor_data.sensor_id
+
+ # Hardware device
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, sensor_data.device_id)},
+ name=sensor_data.device_name,
+ model=sensor_data.device_type,
+ )
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
+ self.value = sensor_data.value
+ self._attr_extra_state_attributes = {
+ STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
+ STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
+ }
+ else:
+ self.value = None
+
+ super()._handle_coordinator_update()
+
+ @property
+ def native_value(self) -> str | None:
+ """Return the formatted sensor value or None if no value is available."""
+ if self.value is not None and self.value != "-":
+ return self._format_number_value(self.value)
+ return None
+
+ @staticmethod
+ def _format_number_value(number_str: str) -> str:
+ return number_str.replace(",", ".")
diff --git a/homeassistant/components/libre_hardware_monitor/strings.json b/homeassistant/components/libre_hardware_monitor/strings.json
new file mode 100644
index 00000000000..6a40a8dbb7a
--- /dev/null
+++ b/homeassistant/components/libre_hardware_monitor/strings.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "no_devices": "[%key:common::config_flow::abort::no_devices_found%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "The IP address or hostname of the system running Libre Hardware Monitor.",
+ "port": "The port of your Libre Hardware Monitor web server. By default 8085."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py
index 3f9d2be4bec..801d07fdc7d 100644
--- a/homeassistant/components/lidarr/coordinator.py
+++ b/homeassistant/components/lidarr/coordinator.py
@@ -35,7 +35,7 @@ T = TypeVar("T", bound=list[LidarrRootFolder] | LidarrQueue | str | LidarrAlbum
type LidarrConfigEntry = ConfigEntry[LidarrData]
-class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC):
+class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]):
"""Data update coordinator for the Lidarr integration."""
config_entry: LidarrConfigEntry
diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json
index 3c755779846..d7f50ca493b 100644
--- a/homeassistant/components/lifx/manifest.json
+++ b/homeassistant/components/lifx/manifest.json
@@ -54,6 +54,6 @@
"requirements": [
"aiolifx==1.2.1",
"aiolifx-effects==0.3.2",
- "aiolifx-themes==0.6.4"
+ "aiolifx-themes==1.0.2"
]
}
diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml
index ac4fbfc15af..9e93ace3744 100644
--- a/homeassistant/components/lifx/services.yaml
+++ b/homeassistant/components/lifx/services.yaml
@@ -127,6 +127,13 @@ effect_colorloop:
min: 1
max: 100
unit_of_measurement: "%"
+ transition:
+ required: false
+ selector:
+ number:
+ min: 0
+ max: 3600
+ unit_of_measurement: seconds
period:
default: 60
selector:
diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json
index be0485c6dff..d6b3a2c5404 100644
--- a/homeassistant/components/lifx/strings.json
+++ b/homeassistant/components/lifx/strings.json
@@ -149,6 +149,10 @@
"name": "[%key:component::lifx::services::effect_pulse::fields::period::name%]",
"description": "Duration between color changes."
},
+ "transition": {
+ "name": "Transition",
+ "description": "Duration of the transition between colors."
+ },
"change": {
"name": "Change",
"description": "Hue movement per period, in degrees on a color wheel."
diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json
index 7a53f2569e7..a17d6793b83 100644
--- a/homeassistant/components/light/strings.json
+++ b/homeassistant/components/light/strings.json
@@ -57,7 +57,8 @@
},
"extra_fields": {
"brightness_pct": "Brightness",
- "flash": "Flash"
+ "flash": "Flash",
+ "for": "[%key:common::device_automation::extra_fields::for%]"
}
},
"entity_component": {
diff --git a/homeassistant/components/lightwave/sensor.py b/homeassistant/components/lightwave/sensor.py
index 721c508dd99..05dd04dd3cd 100644
--- a/homeassistant/components/lightwave/sensor.py
+++ b/homeassistant/components/lightwave/sensor.py
@@ -53,7 +53,7 @@ class LightwaveBattery(SensorEntity):
def update(self) -> None:
"""Communicate with a Lightwave RTF Proxy to get state."""
- (dummy_temp, dummy_targ, battery, dummy_output) = self._lwlink.read_trv_status(
- self._serial
+ (_dummy_temp, _dummy_targ, battery, _dummy_output) = (
+ self._lwlink.read_trv_status(self._serial)
)
self._attr_native_value = battery
diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json
index 86a95b59b18..1ee6b899905 100644
--- a/homeassistant/components/litterrobot/icons.json
+++ b/homeassistant/components/litterrobot/icons.json
@@ -31,11 +31,29 @@
"cycle_delay": {
"default": "mdi:timer-outline"
},
+ "globe_brightness": {
+ "default": "mdi:lightbulb-question",
+ "state": {
+ "low": "mdi:lightbulb-on-30",
+ "medium": "mdi:lightbulb-on-50",
+ "high": "mdi:lightbulb-on"
+ }
+ },
+ "globe_light": {
+ "state": {
+ "off": "mdi:lightbulb-off",
+ "on": "mdi:lightbulb-on",
+ "auto": "mdi:lightbulb-auto"
+ }
+ },
"meal_insert_size": {
"default": "mdi:scale"
}
},
"sensor": {
+ "food_dispensed_today": {
+ "default": "mdi:counter"
+ },
"hopper_status": {
"default": "mdi:filter",
"state": {
diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json
index e67c681ac53..31a0601a8e7 100644
--- a/homeassistant/components/litterrobot/manifest.json
+++ b/homeassistant/components/litterrobot/manifest.json
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
- "requirements": ["pylitterbot==2024.2.3"]
+ "requirements": ["pylitterbot==2024.2.4"]
}
diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml
index 82f01f64d18..3b26500da97 100644
--- a/homeassistant/components/litterrobot/quality_scale.yaml
+++ b/homeassistant/components/litterrobot/quality_scale.yaml
@@ -28,7 +28,7 @@ rules:
docs-configuration-parameters:
status: done
comment: No options to configure
- docs-installation-parameters: todo
+ docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py
index be3a9915940..9ee186006b3 100644
--- a/homeassistant/components/litterrobot/select.py
+++ b/homeassistant/components/litterrobot/select.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot
-from pylitterbot.robot.litterrobot4 import BrightnessLevel
+from pylitterbot.robot.litterrobot4 import BrightnessLevel, NightLightMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfTime
@@ -32,35 +32,73 @@ class RobotSelectEntityDescription(
select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]]
-ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
- LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check
- key="cycle_delay",
- translation_key="cycle_delay",
- unit_of_measurement=UnitOfTime.MINUTES,
- current_fn=lambda robot: robot.clean_cycle_wait_time_minutes,
- options_fn=lambda robot: robot.VALID_WAIT_TIMES,
- select_fn=lambda robot, opt: robot.set_wait_time(int(opt)),
- ),
- LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str](
- key="panel_brightness",
- translation_key="brightness_level",
- current_fn=(
- lambda robot: bri.name.lower()
- if (bri := robot.panel_brightness) is not None
- else None
- ),
- options_fn=lambda _: [level.name.lower() for level in BrightnessLevel],
- select_fn=(
- lambda robot, opt: robot.set_panel_brightness(BrightnessLevel[opt.upper()])
+ROBOT_SELECT_MAP: dict[type[Robot], tuple[RobotSelectEntityDescription, ...]] = {
+ LitterRobot: (
+ RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check
+ key="cycle_delay",
+ translation_key="cycle_delay",
+ unit_of_measurement=UnitOfTime.MINUTES,
+ current_fn=lambda robot: robot.clean_cycle_wait_time_minutes,
+ options_fn=lambda robot: robot.VALID_WAIT_TIMES,
+ select_fn=lambda robot, opt: robot.set_wait_time(int(opt)),
),
),
- FeederRobot: RobotSelectEntityDescription[FeederRobot, float](
- key="meal_insert_size",
- translation_key="meal_insert_size",
- unit_of_measurement="cups",
- current_fn=lambda robot: robot.meal_insert_size,
- options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES,
- select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)),
+ LitterRobot4: (
+ RobotSelectEntityDescription[LitterRobot4, str](
+ key="globe_brightness",
+ translation_key="globe_brightness",
+ current_fn=(
+ lambda robot: bri.name.lower()
+ if (bri := robot.night_light_level) is not None
+ else None
+ ),
+ options_fn=lambda _: [level.name.lower() for level in BrightnessLevel],
+ select_fn=(
+ lambda robot, opt: robot.set_night_light_brightness(
+ BrightnessLevel[opt.upper()]
+ )
+ ),
+ ),
+ RobotSelectEntityDescription[LitterRobot4, str](
+ key="globe_light",
+ translation_key="globe_light",
+ current_fn=(
+ lambda robot: mode.name.lower()
+ if (mode := robot.night_light_mode) is not None
+ else None
+ ),
+ options_fn=lambda _: [mode.name.lower() for mode in NightLightMode],
+ select_fn=(
+ lambda robot, opt: robot.set_night_light_mode(
+ NightLightMode[opt.upper()]
+ )
+ ),
+ ),
+ RobotSelectEntityDescription[LitterRobot4, str](
+ key="panel_brightness",
+ translation_key="brightness_level",
+ current_fn=(
+ lambda robot: bri.name.lower()
+ if (bri := robot.panel_brightness) is not None
+ else None
+ ),
+ options_fn=lambda _: [level.name.lower() for level in BrightnessLevel],
+ select_fn=(
+ lambda robot, opt: robot.set_panel_brightness(
+ BrightnessLevel[opt.upper()]
+ )
+ ),
+ ),
+ ),
+ FeederRobot: (
+ RobotSelectEntityDescription[FeederRobot, float](
+ key="meal_insert_size",
+ translation_key="meal_insert_size",
+ unit_of_measurement="cups",
+ current_fn=lambda robot: robot.meal_insert_size,
+ options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES,
+ select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)),
+ ),
),
}
@@ -77,8 +115,9 @@ async def async_setup_entry(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
- for robot_type, description in ROBOT_SELECT_MAP.items()
+ for robot_type, descriptions in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
+ for description in descriptions
)
diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py
index aa7c3a451be..ecbf805bea0 100644
--- a/homeassistant/components/litterrobot/sensor.py
+++ b/homeassistant/components/litterrobot/sensor.py
@@ -163,6 +163,17 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
),
],
FeederRobot: [
+ RobotSensorEntityDescription[FeederRobot](
+ key="food_dispensed_today",
+ translation_key="food_dispensed_today",
+ state_class=SensorStateClass.TOTAL,
+ last_reset_fn=dt_util.start_of_local_day,
+ value_fn=(
+ lambda robot: (
+ robot.get_food_dispensed_since(dt_util.start_of_local_day())
+ )
+ ),
+ ),
RobotSensorEntityDescription[FeederRobot](
key="food_level",
translation_key="food_level",
@@ -170,7 +181,23 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
icon_fn=lambda state: icon_for_gauge_level(state, 10),
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda robot: robot.food_level,
- )
+ ),
+ RobotSensorEntityDescription[FeederRobot](
+ key="last_feeding",
+ translation_key="last_feeding",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=(
+ lambda robot: (
+ robot.last_feeding["timestamp"] if robot.last_feeding else None
+ )
+ ),
+ ),
+ RobotSensorEntityDescription[FeederRobot](
+ key="next_feeding",
+ translation_key="next_feeding",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=lambda robot: robot.next_feeding,
+ ),
],
}
diff --git a/homeassistant/components/litterrobot/services.yaml b/homeassistant/components/litterrobot/services.yaml
index 48d17dfdcf7..24171a8b6a6 100644
--- a/homeassistant/components/litterrobot/services.yaml
+++ b/homeassistant/components/litterrobot/services.yaml
@@ -3,6 +3,7 @@
set_sleep_mode:
target:
entity:
+ domain: vacuum
integration: litterrobot
fields:
enabled:
diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json
index 35aff0f9105..58ed6fd9eec 100644
--- a/homeassistant/components/litterrobot/strings.json
+++ b/homeassistant/components/litterrobot/strings.json
@@ -59,6 +59,10 @@
}
},
"sensor": {
+ "food_dispensed_today": {
+ "name": "Food dispensed today",
+ "unit_of_measurement": "cups"
+ },
"food_level": {
"name": "Food level"
},
@@ -73,12 +77,18 @@
"empty": "[%key:common::state::empty%]"
}
},
+ "last_feeding": {
+ "name": "Last feeding"
+ },
"last_seen": {
"name": "Last seen"
},
"litter_level": {
"name": "Litter level"
},
+ "next_feeding": {
+ "name": "Next feeding"
+ },
"pet_weight": {
"name": "Pet weight"
},
@@ -134,6 +144,22 @@
"cycle_delay": {
"name": "Clean cycle wait time minutes"
},
+ "globe_brightness": {
+ "name": "Globe brightness",
+ "state": {
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
+ }
+ },
+ "globe_light": {
+ "name": "Globe light",
+ "state": {
+ "auto": "[%key:common::state::auto%]",
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
+ },
"meal_insert_size": {
"name": "Meal insert size"
},
@@ -147,6 +173,9 @@
}
},
"switch": {
+ "gravity_mode": {
+ "name": "Gravity mode"
+ },
"night_light_mode": {
"name": "Night light mode"
},
@@ -180,5 +209,11 @@
}
}
}
+ },
+ "issues": {
+ "deprecated_entity": {
+ "title": "{name} is deprecated",
+ "description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
+ }
}
}
diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py
index 5924f8f094a..c9eff5be4c0 100644
--- a/homeassistant/components/litterrobot/switch.py
+++ b/homeassistant/components/litterrobot/switch.py
@@ -6,13 +6,24 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Generic
-from pylitterbot import FeederRobot, LitterRobot
+from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
-from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
+from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
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 .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -26,20 +37,35 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti
value_fn: Callable[[_WhiskerEntityT], bool]
-ROBOT_SWITCHES = [
- RobotSwitchEntityDescription[LitterRobot | FeederRobot](
- key="night_light_mode_enabled",
- translation_key="night_light_mode",
- set_fn=lambda robot, value: robot.set_night_light(value),
- value_fn=lambda robot: robot.night_light_mode_enabled,
+NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION = RobotSwitchEntityDescription[
+ LitterRobot | FeederRobot
+](
+ key="night_light_mode_enabled",
+ translation_key="night_light_mode",
+ set_fn=lambda robot, value: robot.set_night_light(value),
+ value_fn=lambda robot: robot.night_light_mode_enabled,
+)
+
+SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
+ FeederRobot: (
+ RobotSwitchEntityDescription[FeederRobot](
+ key="gravity_mode",
+ translation_key="gravity_mode",
+ set_fn=lambda robot, value: robot.set_gravity_mode(value),
+ value_fn=lambda robot: robot.gravity_mode_enabled,
+ ),
+ NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,
),
- RobotSwitchEntityDescription[LitterRobot | FeederRobot](
- key="panel_lock_enabled",
- translation_key="panel_lockout",
- set_fn=lambda robot, value: robot.set_panel_lockout(value),
- value_fn=lambda robot: robot.panel_lock_enabled,
+ LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,),
+ Robot: ( # type: ignore[type-abstract] # only used for isinstance check
+ RobotSwitchEntityDescription[LitterRobot | FeederRobot](
+ key="panel_lock_enabled",
+ translation_key="panel_lockout",
+ set_fn=lambda robot, value: robot.set_panel_lockout(value),
+ value_fn=lambda robot: robot.panel_lock_enabled,
+ ),
),
-]
+}
async def async_setup_entry(
@@ -49,12 +75,54 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot switches using config entry."""
coordinator = entry.runtime_data
- async_add_entities(
+ entities = [
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
- for description in ROBOT_SWITCHES
for robot in coordinator.account.robots
- if isinstance(robot, (LitterRobot, FeederRobot))
- )
+ for robot_type, entity_descriptions in SWITCH_MAP.items()
+ if isinstance(robot, robot_type)
+ for description in entity_descriptions
+ ]
+
+ ent_reg = er.async_get(hass)
+
+ def add_deprecated_entity(
+ robot: LitterRobot4,
+ description: RobotSwitchEntityDescription,
+ entity_cls: type[RobotSwitchEntity],
+ ) -> None:
+ """Add deprecated entities."""
+ unique_id = f"{robot.serial}-{description.key}"
+ if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
+ 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_{unique_id}",
+ )
+ elif entity_entry:
+ entities.append(entity_cls(robot, coordinator, description))
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_entity_{unique_id}",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_entity",
+ translation_placeholders={
+ "name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
+ "entity": entity_id,
+ },
+ )
+
+ for robot in coordinator.account.get_robots(LitterRobot4):
+ add_deprecated_entity(
+ robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
+ )
+
+ async_add_entities(entities)
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py
index 4d9dfe5074d..8f3a176175b 100644
--- a/homeassistant/components/litterrobot/update.py
+++ b/homeassistant/components/litterrobot/update.py
@@ -26,6 +26,7 @@ FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription(
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
)
+RELEASE_URL = "https://www.litter-robot.com/releases.html"
async def async_setup_entry(
@@ -48,6 +49,7 @@ async def async_setup_entry(
class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
"""A class that describes robot update entities."""
+ _attr_release_url = RELEASE_URL
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
diff --git a/homeassistant/components/local_file/__init__.py b/homeassistant/components/local_file/__init__.py
index 70144cd0704..183c8b7ee82 100644
--- a/homeassistant/components/local_file/__init__.py
+++ b/homeassistant/components/local_file/__init__.py
@@ -22,7 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -30,8 +29,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Local file config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py
index c4b83f9407a..206e4c2a7c8 100644
--- a/homeassistant/components/local_file/config_flow.py
+++ b/homeassistant/components/local_file/config_flow.py
@@ -65,6 +65,7 @@ class LocalFileConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py
index 30df24ea854..e6e5ca8b18b 100644
--- a/homeassistant/components/local_todo/todo.py
+++ b/homeassistant/components/local_todo/todo.py
@@ -132,7 +132,7 @@ class LocalTodoListEntity(TodoListEntity):
self._store = store
self._calendar = calendar
self._calendar_lock = asyncio.Lock()
- self._attr_name = name.capitalize()
+ self._attr_name = name
self._attr_unique_id = unique_id
def _new_todo_store(self) -> TodoStore:
@@ -196,11 +196,11 @@ class LocalTodoListEntity(TodoListEntity):
item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)}
if uid not in item_idx:
raise HomeAssistantError(
- "Item '{uid}' not found in todo list {self.entity_id}"
+ f"Item '{uid}' not found in todo list {self.entity_id}"
)
if previous_uid and previous_uid not in item_idx:
raise HomeAssistantError(
- "Item '{previous_uid}' not found in todo list {self.entity_id}"
+ f"Item '{previous_uid}' not found in todo list {self.entity_id}"
)
dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0
src_idx = item_idx[uid]
diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index 05aed8a827f..dcb2ed794e7 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -13,28 +13,16 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ( # noqa: F401
- _DEPRECATED_STATE_JAMMED,
- _DEPRECATED_STATE_LOCKED,
- _DEPRECATED_STATE_LOCKING,
- _DEPRECATED_STATE_UNLOCKED,
- _DEPRECATED_STATE_UNLOCKING,
+from homeassistant.const import (
ATTR_CODE,
ATTR_CODE_FORMAT,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
- STATE_OPEN,
- STATE_OPENING,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType
@@ -317,11 +305,3 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return
self._lock_option_default_code = ""
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py
index 2e2ffddac88..de2ff570f0c 100644
--- a/homeassistant/components/logbook/__init__.py
+++ b/homeassistant/components/logbook/__init__.py
@@ -115,7 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_log_entry(hass, name, message, domain, entity_id, service.context)
frontend.async_register_built_in_panel(
- hass, "logbook", "logbook", "hass:format-list-bulleted-type"
+ hass, "logbook", "logbook", "mdi:format-list-bulleted-type"
)
recorder_conf = config.get(RECORDER_DOMAIN, {})
diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py
index 4fa0da9033a..238e6a0dda8 100644
--- a/homeassistant/components/logbook/helpers.py
+++ b/homeassistant/components/logbook/helpers.py
@@ -5,8 +5,9 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
-from homeassistant.components.sensor import ATTR_STATE_CLASS
+from homeassistant.components.sensor import ATTR_STATE_CLASS, NON_NUMERIC_DEVICE_CLASSES
from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
ATTR_DEVICE_ID,
ATTR_DOMAIN,
ATTR_ENTITY_ID,
@@ -28,7 +29,13 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.event_type import EventType
-from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
+from .const import (
+ ALWAYS_CONTINUOUS_DOMAINS,
+ AUTOMATION_EVENTS,
+ BUILT_IN_EVENTS,
+ DOMAIN,
+ SENSOR_DOMAIN,
+)
from .models import LogbookConfig
@@ -38,8 +45,10 @@ def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[st
return [
entity_id
for entity_id in entity_ids
- if split_entity_id(entity_id)[0] not in ALWAYS_CONTINUOUS_DOMAINS
- and not is_sensor_continuous(hass, ent_reg, entity_id)
+ if (domain := split_entity_id(entity_id)[0]) not in ALWAYS_CONTINUOUS_DOMAINS
+ and not (
+ domain == SENSOR_DOMAIN and is_sensor_continuous(hass, ent_reg, entity_id)
+ )
]
@@ -214,6 +223,10 @@ def async_subscribe_events(
)
+def _device_class_is_numeric(device_class: str | None) -> bool:
+ return device_class is not None and device_class not in NON_NUMERIC_DEVICE_CLASSES
+
+
def is_sensor_continuous(
hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str
) -> bool:
@@ -233,7 +246,11 @@ def is_sensor_continuous(
# has a unit_of_measurement or state_class, and filter if
# it does
if (state := hass.states.get(entity_id)) and (attributes := state.attributes):
- return ATTR_UNIT_OF_MEASUREMENT in attributes or ATTR_STATE_CLASS in attributes
+ return (
+ ATTR_UNIT_OF_MEASUREMENT in attributes
+ or ATTR_STATE_CLASS in attributes
+ or _device_class_is_numeric(attributes.get(ATTR_DEVICE_CLASS))
+ )
# If its not in the state machine, we need to check
# the entity registry to see if its a sensor
# filter with a state class. We do not check
@@ -243,8 +260,10 @@ def is_sensor_continuous(
# the state machine will always have the state.
return bool(
(entry := ent_reg.async_get(entity_id))
- and entry.capabilities
- and entry.capabilities.get(ATTR_STATE_CLASS)
+ and (
+ (entry.capabilities and entry.capabilities.get(ATTR_STATE_CLASS))
+ or _device_class_is_numeric(entry.device_class)
+ )
)
@@ -258,6 +277,12 @@ def _is_state_filtered(new_state: State, old_state: State) -> bool:
new_state.state == old_state.state
or new_state.last_changed != new_state.last_updated
or new_state.domain in ALWAYS_CONTINUOUS_DOMAINS
- or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes
- or ATTR_STATE_CLASS in new_state.attributes
+ or (
+ new_state.domain == SENSOR_DOMAIN
+ and (
+ ATTR_UNIT_OF_MEASUREMENT in new_state.attributes
+ or ATTR_STATE_CLASS in new_state.attributes
+ or _device_class_is_numeric(new_state.attributes.get(ATTR_DEVICE_CLASS))
+ )
+ )
)
diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json
index b6b68a1489e..5a84fdb85e5 100644
--- a/homeassistant/components/logbook/manifest.json
+++ b/homeassistant/components/logbook/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "logbook",
- "name": "Logbook",
+ "name": "Activity",
"codeowners": ["@home-assistant/core"],
"dependencies": ["frontend", "http", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/logbook",
diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json
index 5a38b57a9b7..8c725a764c6 100644
--- a/homeassistant/components/logbook/strings.json
+++ b/homeassistant/components/logbook/strings.json
@@ -1,9 +1,9 @@
{
- "title": "Logbook",
+ "title": "Activity",
"services": {
"log": {
"name": "Log",
- "description": "Creates a custom entry in the logbook.",
+ "description": "Tracks a custom activity.",
"fields": {
"name": {
"name": "[%key:common::config_flow::data::name%]",
@@ -11,15 +11,15 @@
},
"message": {
"name": "Message",
- "description": "Message of the logbook entry."
+ "description": "Message of the activity."
},
"entity_id": {
"name": "Entity ID",
- "description": "Entity to reference in the logbook entry."
+ "description": "Entity to reference in the activity."
},
"domain": {
"name": "Domain",
- "description": "Determines which icon is used in the logbook entry. The icon illustrates the integration domain related to this logbook entry."
+ "description": "Determines which icon is used in the activity. The icon illustrates the integration domain related to this activity."
}
}
}
diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py
index 0450c62338d..ac1c9c5abff 100644
--- a/homeassistant/components/lovelace/const.py
+++ b/homeassistant/components/lovelace/const.py
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
DOMAIN = "lovelace"
LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN)
-DEFAULT_ICON = "hass:view-dashboard"
+DEFAULT_ICON = "mdi:view-dashboard"
MODE_YAML = "yaml"
MODE_STORAGE = "storage"
diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py
index ddb54e7618f..4faf4f15b08 100644
--- a/homeassistant/components/lovelace/dashboard.py
+++ b/homeassistant/components/lovelace/dashboard.py
@@ -215,12 +215,12 @@ class LovelaceYAML(LovelaceConfig):
async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config."""
- config, json = await self._async_load_or_cached(force)
+ config, _json = await self._async_load_or_cached(force)
return config
async def async_json(self, force: bool) -> json_fragment:
"""Return JSON representation of the config."""
- config, json = await self._async_load_or_cached(force)
+ _config, json = await self._async_load_or_cached(force)
return json
async def _async_load_or_cached(
diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py
new file mode 100644
index 00000000000..d507f91a4f3
--- /dev/null
+++ b/homeassistant/components/lunatone/__init__.py
@@ -0,0 +1,64 @@
+"""The Lunatone integration."""
+
+from typing import Final
+
+from lunatone_rest_api_client import Auth, Devices, Info
+
+from homeassistant.const import CONF_URL, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+from .coordinator import (
+ LunatoneConfigEntry,
+ LunatoneData,
+ LunatoneDevicesDataUpdateCoordinator,
+ LunatoneInfoDataUpdateCoordinator,
+)
+
+PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
+ """Set up Lunatone from a config entry."""
+ auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
+ info_api = Info(auth_api)
+ devices_api = Devices(auth_api)
+
+ coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
+ await coordinator_info.async_config_entry_first_refresh()
+
+ if info_api.serial_number is None:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN, translation_key="missing_device_info"
+ )
+
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, str(info_api.serial_number))},
+ name=info_api.name,
+ manufacturer="Lunatone",
+ sw_version=info_api.version,
+ hw_version=info_api.data.device.pcb,
+ configuration_url=entry.data[CONF_URL],
+ serial_number=str(info_api.serial_number),
+ model_id=(
+ f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
+ ),
+ )
+
+ coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
+ await coordinator_devices.async_config_entry_first_refresh()
+
+ entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py
new file mode 100644
index 00000000000..4dc5d8c03ec
--- /dev/null
+++ b/homeassistant/components/lunatone/config_flow.py
@@ -0,0 +1,83 @@
+"""Config flow for Lunatone."""
+
+from typing import Any, Final
+
+import aiohttp
+from lunatone_rest_api_client import Auth, Info
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
+)
+from homeassistant.const import CONF_URL
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
+ {vol.Required(CONF_URL, default="http://"): cv.string},
+)
+
+
+def compose_title(name: str | None, serial_number: int) -> str:
+ """Compose a title string from a given name and serial number."""
+ return f"{name or 'DALI Gateway'} {serial_number}"
+
+
+class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Lunatone config flow."""
+
+ VERSION = 1
+ MINOR_VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ url = user_input[CONF_URL]
+ data = {CONF_URL: url}
+ self._async_abort_entries_match(data)
+ auth_api = Auth(
+ session=async_get_clientsession(self.hass),
+ base_url=url,
+ )
+ info_api = Info(auth_api)
+ try:
+ await info_api.async_update()
+ except aiohttp.InvalidUrlClientError:
+ errors["base"] = "invalid_url"
+ except aiohttp.ClientConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ if info_api.data is None or info_api.serial_number is None:
+ errors["base"] = "missing_device_info"
+ else:
+ await self.async_set_unique_id(str(info_api.serial_number))
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch()
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates=data,
+ title=compose_title(info_api.name, info_api.serial_number),
+ )
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=compose_title(info_api.name, info_api.serial_number),
+ data={CONF_URL: url},
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=DATA_SCHEMA,
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration flow initialized by the user."""
+ return await self.async_step_user(user_input)
diff --git a/homeassistant/components/lunatone/const.py b/homeassistant/components/lunatone/const.py
new file mode 100644
index 00000000000..ad7eb57affa
--- /dev/null
+++ b/homeassistant/components/lunatone/const.py
@@ -0,0 +1,5 @@
+"""Constants for the Lunatone integration."""
+
+from typing import Final
+
+DOMAIN: Final = "lunatone"
diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py
new file mode 100644
index 00000000000..f9f15ed4629
--- /dev/null
+++ b/homeassistant/components/lunatone/coordinator.py
@@ -0,0 +1,101 @@
+"""Coordinator for handling data fetching and updates."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+import aiohttp
+from lunatone_rest_api_client import Device, Devices, Info
+from lunatone_rest_api_client.models import InfoData
+
+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__)
+
+DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
+
+
+@dataclass
+class LunatoneData:
+ """Data for Lunatone integration."""
+
+ coordinator_info: LunatoneInfoDataUpdateCoordinator
+ coordinator_devices: LunatoneDevicesDataUpdateCoordinator
+
+
+type LunatoneConfigEntry = ConfigEntry[LunatoneData]
+
+
+class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
+ """Data update coordinator for Lunatone info."""
+
+ config_entry: LunatoneConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=f"{DOMAIN}-info",
+ always_update=False,
+ )
+ self.info_api = info_api
+
+ async def _async_update_data(self) -> InfoData:
+ """Update info data."""
+ try:
+ await self.info_api.async_update()
+ except aiohttp.ClientConnectionError as ex:
+ raise UpdateFailed(
+ "Unable to retrieve info data from Lunatone REST API"
+ ) from ex
+
+ if self.info_api.data is None:
+ raise UpdateFailed("Did not receive info data from Lunatone REST API")
+ return self.info_api.data
+
+
+class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
+ """Data update coordinator for Lunatone devices."""
+
+ config_entry: LunatoneConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: LunatoneConfigEntry,
+ devices_api: Devices,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=f"{DOMAIN}-devices",
+ always_update=False,
+ update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
+ )
+ self.devices_api = devices_api
+
+ async def _async_update_data(self) -> dict[int, Device]:
+ """Update devices data."""
+ try:
+ await self.devices_api.async_update()
+ except aiohttp.ClientConnectionError as ex:
+ raise UpdateFailed(
+ "Unable to retrieve devices data from Lunatone REST API"
+ ) from ex
+
+ if self.devices_api.data is None:
+ raise UpdateFailed("Did not receive devices data from Lunatone REST API")
+
+ return {device.id: device for device in self.devices_api.devices}
diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py
new file mode 100644
index 00000000000..416412aea6e
--- /dev/null
+++ b/homeassistant/components/lunatone/light.py
@@ -0,0 +1,103 @@
+"""Platform for Lunatone light integration."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from homeassistant.components.light import ColorMode, LightEntity
+from homeassistant.core import HomeAssistant, callback
+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
+from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
+
+PARALLEL_UPDATES = 0
+STATUS_UPDATE_DELAY = 0.04
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LunatoneConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Lunatone Light platform."""
+ coordinator_info = config_entry.runtime_data.coordinator_info
+ coordinator_devices = config_entry.runtime_data.coordinator_devices
+
+ async_add_entities(
+ [
+ LunatoneLight(
+ coordinator_devices, device_id, coordinator_info.data.device.serial
+ )
+ for device_id in coordinator_devices.data
+ ]
+ )
+
+
+class LunatoneLight(
+ CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
+):
+ """Representation of a Lunatone light."""
+
+ _attr_color_mode = ColorMode.ONOFF
+ _attr_supported_color_modes = {ColorMode.ONOFF}
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ coordinator: LunatoneDevicesDataUpdateCoordinator,
+ device_id: int,
+ interface_serial_number: int,
+ ) -> None:
+ """Initialize a LunatoneLight."""
+ super().__init__(coordinator=coordinator)
+ self._device_id = device_id
+ self._interface_serial_number = interface_serial_number
+ self._device = self.coordinator.data.get(self._device_id)
+ self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return the device info."""
+ assert self.unique_id
+ name = self._device.name if self._device is not None else None
+ return DeviceInfo(
+ identifiers={(DOMAIN, self.unique_id)},
+ name=name,
+ via_device=(DOMAIN, str(self._interface_serial_number)),
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self._device is not None
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if light is on."""
+ return self._device is not None and self._device.is_on
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._device = self.coordinator.data.get(self._device_id)
+ self.async_write_ha_state()
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Instruct the light to turn on."""
+ assert self._device
+ await self._device.switch_on()
+ await asyncio.sleep(STATUS_UPDATE_DELAY)
+ await self.coordinator.async_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Instruct the light to turn off."""
+ assert self._device
+ await self._device.switch_off()
+ await asyncio.sleep(STATUS_UPDATE_DELAY)
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json
new file mode 100644
index 00000000000..8db658869d5
--- /dev/null
+++ b/homeassistant/components/lunatone/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "lunatone",
+ "name": "Lunatone",
+ "codeowners": ["@MoonDevLT"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/lunatone",
+ "integration_type": "hub",
+ "iot_class": "local_polling",
+ "quality_scale": "silver",
+ "requirements": ["lunatone-rest-api-client==0.4.8"]
+}
diff --git a/homeassistant/components/lunatone/quality_scale.yaml b/homeassistant/components/lunatone/quality_scale.yaml
new file mode 100644
index 00000000000..c118c210d53
--- /dev/null
+++ b/homeassistant/components/lunatone/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: exempt
+ comment: |
+ This integration has only one platform which uses a coordinator.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: no actions
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: todo
+ comment: Discovery not yet supported
+ discovery:
+ status: todo
+ comment: Discovery not yet supported
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json
new file mode 100644
index 00000000000..71f4b23b058
--- /dev/null
+++ b/homeassistant/components/lunatone/strings.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "description": "[%key:common::config_flow::description::confirm_setup%]"
+ },
+ "user": {
+ "description": "Connect to the API of your Lunatone DALI IoT Gateway.",
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "url": "The URL of the Lunatone gateway device."
+ }
+ },
+ "reconfigure": {
+ "description": "Update the URL.",
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]"
+ },
+ "data_description": {
+ "url": "[%key:component::lunatone::config::step::user::data_description::url%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
+ "missing_device_info": "Failed to read device information. Check the network connection of the device"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ }
+ }
+}
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
index b489fe9dba7..bde3e7d4ec4 100644
--- a/homeassistant/components/lutron_caseta/__init__.py
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -8,7 +8,7 @@ import logging
import ssl
from typing import Any, cast
-from pylutron_caseta import BUTTON_STATUS_PRESSED
+from pylutron_caseta import BUTTON_STATUS_MULTITAP, BUTTON_STATUS_PRESSED
from pylutron_caseta.smartbridge import Smartbridge
import voluptuous as vol
@@ -25,6 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
+ ACTION_MULTITAP,
ACTION_PRESS,
ACTION_RELEASE,
ATTR_ACTION,
@@ -448,6 +449,8 @@ def _async_subscribe_keypad_events(
if event_type == BUTTON_STATUS_PRESSED:
action = ACTION_PRESS
+ elif event_type == BUTTON_STATUS_MULTITAP:
+ action = ACTION_MULTITAP
else:
action = ACTION_RELEASE
diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py
index 26a83de6f4b..07f60ae0b96 100644
--- a/homeassistant/components/lutron_caseta/const.py
+++ b/homeassistant/components/lutron_caseta/const.py
@@ -29,6 +29,7 @@ ATTR_DEVICE_NAME = "device_name"
ATTR_AREA_NAME = "area_name"
ATTR_ACTION = "action"
+ACTION_MULTITAP = "multi_tap"
ACTION_PRESS = "press"
ACTION_RELEASE = "release"
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
index e05fddb996f..ad1530bef5e 100644
--- a/homeassistant/components/lutron_caseta/cover.py
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -1,5 +1,6 @@
"""Support for Lutron Caseta shades."""
+from enum import Enum
from typing import Any
from homeassistant.components.cover import (
@@ -17,6 +18,14 @@ from .entity import LutronCasetaUpdatableEntity
from .models import LutronCasetaConfigEntry
+class ShadeMovementDirection(Enum):
+ """Enum for shade movement direction."""
+
+ OPENING = "opening"
+ CLOSING = "closing"
+ STOPPED = "stopped"
+
+
class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
"""Representation of a Lutron shade with open/close functionality."""
@@ -27,6 +36,8 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
| CoverEntityFeature.SET_POSITION
)
_attr_device_class = CoverDeviceClass.SHADE
+ _previous_position: int | None = None
+ _movement_direction: ShadeMovementDirection | None = None
@property
def is_closed(self) -> bool:
@@ -38,19 +49,50 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
"""Return the current position of cover."""
return self._device["current_state"]
+ def _handle_bridge_update(self) -> None:
+ """Handle updated data from the bridge and track movement direction."""
+ current_position = self.current_cover_position
+
+ # Track movement direction based on position changes or endpoint status
+ if self._previous_position is not None:
+ if current_position > self._previous_position or current_position >= 100:
+ # Moving up or at fully open
+ self._movement_direction = ShadeMovementDirection.OPENING
+ elif current_position < self._previous_position or current_position <= 0:
+ # Moving down or at fully closed
+ self._movement_direction = ShadeMovementDirection.CLOSING
+ else:
+ # Stopped
+ self._movement_direction = ShadeMovementDirection.STOPPED
+
+ self._previous_position = current_position
+ super()._handle_bridge_update()
+
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self._smartbridge.lower_cover(self.device_id)
+ # Use set_value to avoid the stuttering issue
+ await self._smartbridge.set_value(self.device_id, 0)
await self.async_update()
self.async_write_ha_state()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
+ # Send appropriate directional command before stop to ensure it works correctly
+ # Use tracked direction if moving, otherwise use position-based heuristic
+ if self._movement_direction == ShadeMovementDirection.OPENING or (
+ self._movement_direction in (ShadeMovementDirection.STOPPED, None)
+ and self.current_cover_position >= 50
+ ):
+ await self._smartbridge.raise_cover(self.device_id)
+ else:
+ await self._smartbridge.lower_cover(self.device_id)
+
await self._smartbridge.stop_cover(self.device_id)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self._smartbridge.raise_cover(self.device_id)
+ # Use set_value to avoid the stuttering issue
+ await self._smartbridge.set_value(self.device_id, 100)
await self.async_update()
self.async_write_ha_state()
diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py
index 31c9a0e171d..b3bfaaa7c62 100644
--- a/homeassistant/components/lutron_caseta/device_trigger.py
+++ b/homeassistant/components/lutron_caseta/device_trigger.py
@@ -21,6 +21,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
+ ACTION_MULTITAP,
ACTION_PRESS,
ACTION_RELEASE,
ATTR_ACTION,
@@ -39,7 +40,7 @@ def _reverse_dict(forward_dict: dict) -> dict:
return {v: k for k, v in forward_dict.items()}
-SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE]
+SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_MULTITAP, ACTION_RELEASE]
LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py
index 5ab211ed87b..8cae22f5042 100644
--- a/homeassistant/components/lutron_caseta/entity.py
+++ b/homeassistant/components/lutron_caseta/entity.py
@@ -65,7 +65,11 @@ class LutronCasetaEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
- self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
+ self._smartbridge.add_subscriber(self.device_id, self._handle_bridge_update)
+
+ def _handle_bridge_update(self) -> None:
+ """Handle updated data from the bridge."""
+ self.async_write_ha_state()
def _handle_none_serial(self, serial: str | int | None) -> str | int:
"""Handle None serial returned by RA3 and QSX processors."""
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index 96b00a1f392..0f0c199e448 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
- "requirements": ["pylutron-caseta==0.24.0"],
+ "requirements": ["pylutron-caseta==0.25.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lyric/services.yaml b/homeassistant/components/lyric/services.yaml
index c3c4bc640bf..3dd300f48ad 100644
--- a/homeassistant/components/lyric/services.yaml
+++ b/homeassistant/components/lyric/services.yaml
@@ -1,7 +1,5 @@
set_hold_time:
target:
- device:
- integration: lyric
entity:
integration: lyric
domain: climate
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
index b6e0d863471..6c8f53e4cb2 100644
--- a/homeassistant/components/mastodon/__init__.py
+++ b/homeassistant/components/mastodon/__init__.py
@@ -2,7 +2,14 @@
from __future__ import annotations
-from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError
+from mastodon.Mastodon import (
+ Account,
+ Instance,
+ InstanceV2,
+ Mastodon,
+ MastodonError,
+ MastodonNotFoundError,
+)
from homeassistant.const import (
CONF_ACCESS_TOKEN,
@@ -105,7 +112,11 @@ def setup_mastodon(
entry.data[CONF_ACCESS_TOKEN],
)
- instance = client.instance()
+ try:
+ instance = client.instance_v2()
+ except MastodonNotFoundError:
+ instance = client.instance_v1()
+
account = client.account_verify_credentials()
return client, instance, account
diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py
index 1ae1e6b229e..dbd617eca5f 100644
--- a/homeassistant/components/mastodon/config_flow.py
+++ b/homeassistant/components/mastodon/config_flow.py
@@ -7,7 +7,9 @@ from typing import Any
from mastodon.Mastodon import (
Account,
Instance,
+ InstanceV2,
MastodonNetworkError,
+ MastodonNotFoundError,
MastodonUnauthorizedError,
)
import voluptuous as vol
@@ -61,7 +63,7 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
client_secret: str,
access_token: str,
) -> tuple[
- Instance | None,
+ InstanceV2 | Instance | None,
Account | None,
dict[str, str],
]:
@@ -73,7 +75,10 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
client_secret,
access_token,
)
- instance = client.instance()
+ try:
+ instance = client.instance_v2()
+ except MastodonNotFoundError:
+ instance = client.instance_v1()
account = client.account_verify_credentials()
except MastodonNetworkError:
diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py
index 8a77eebcf7a..9c46f07029b 100644
--- a/homeassistant/components/mastodon/const.py
+++ b/homeassistant/components/mastodon/const.py
@@ -18,3 +18,4 @@ ATTR_CONTENT_WARNING = "content_warning"
ATTR_MEDIA_WARNING = "media_warning"
ATTR_MEDIA = "media"
ATTR_MEDIA_DESCRIPTION = "media_description"
+ATTR_LANGUAGE = "language"
diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py
index 31444413dfd..434f6c0acac 100644
--- a/homeassistant/components/mastodon/diagnostics.py
+++ b/homeassistant/components/mastodon/diagnostics.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
-from mastodon.Mastodon import Account, Instance
+from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonNotFoundError
from homeassistant.core import HomeAssistant
@@ -27,11 +27,16 @@ async def async_get_config_entry_diagnostics(
}
-def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]:
+def get_diagnostics(
+ config_entry: MastodonConfigEntry,
+) -> tuple[InstanceV2 | Instance, Account]:
"""Get mastodon diagnostics."""
client = config_entry.runtime_data.client
- instance = client.instance()
+ try:
+ instance = client.instance_v2()
+ except MastodonNotFoundError:
+ instance = client.instance_v1()
account = client.account_verify_credentials()
return instance, account
diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py
index 0815fee34ec..c5347079a5f 100644
--- a/homeassistant/components/mastodon/services.py
+++ b/homeassistant/components/mastodon/services.py
@@ -15,6 +15,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import (
ATTR_CONTENT_WARNING,
+ ATTR_LANGUAGE,
ATTR_MEDIA,
ATTR_MEDIA_DESCRIPTION,
ATTR_MEDIA_WARNING,
@@ -42,6 +43,7 @@ SERVICE_POST_SCHEMA = vol.Schema(
vol.Required(ATTR_STATUS): str,
vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]),
vol.Optional(ATTR_CONTENT_WARNING): str,
+ vol.Optional(ATTR_LANGUAGE): str,
vol.Optional(ATTR_MEDIA): str,
vol.Optional(ATTR_MEDIA_DESCRIPTION): str,
vol.Optional(ATTR_MEDIA_WARNING): bool,
@@ -82,6 +84,7 @@ def setup_services(hass: HomeAssistant) -> None:
else None
)
spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING)
+ language: str | None = call.data.get(ATTR_LANGUAGE)
media_path: str | None = call.data.get(ATTR_MEDIA)
media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION)
media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING)
@@ -93,6 +96,7 @@ def setup_services(hass: HomeAssistant) -> None:
status=status,
visibility=visibility,
spoiler_text=spoiler_text,
+ language=language,
media_path=media_path,
media_description=media_description,
sensitive=media_warning,
diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml
index 206dc36c1a2..9db51f783b2 100644
--- a/homeassistant/components/mastodon/services.yaml
+++ b/homeassistant/components/mastodon/services.yaml
@@ -21,6 +21,209 @@ post:
content_warning:
selector:
text:
+ language:
+ required: false
+ selector:
+ language:
+ languages:
+ - "aa"
+ - "ab"
+ - "ae"
+ - "af"
+ - "ak"
+ - "am"
+ - "an"
+ - "ar"
+ - "as"
+ - "ast"
+ - "av"
+ - "ay"
+ - "az"
+ - "ba"
+ - "be"
+ - "bg"
+ - "bi"
+ - "bm"
+ - "bn"
+ - "bo"
+ - "br"
+ - "bs"
+ - "ca"
+ - "ce"
+ - "ch"
+ - "chr"
+ - "ckb"
+ - "cnr"
+ - "co"
+ - "cr"
+ - "cs"
+ - "cu"
+ - "cv"
+ - "cy"
+ - "da"
+ - "de"
+ - "dv"
+ - "dz"
+ - "ee"
+ - "el"
+ - "en"
+ - "eo"
+ - "es"
+ - "et"
+ - "eu"
+ - "fa"
+ - "ff"
+ - "fi"
+ - "fj"
+ - "fo" # codespell:ignore fo
+ - "fr"
+ - "fy"
+ - "ga"
+ - "gd"
+ - "gl"
+ - "gu"
+ - "gv"
+ - "ha"
+ - "he"
+ - "hi"
+ - "ho"
+ - "hr"
+ - "ht"
+ - "hu"
+ - "hy"
+ - "hz"
+ - "ia"
+ - "id"
+ - "ie"
+ - "ig"
+ - "ii"
+ - "ik"
+ - "io"
+ - "is"
+ - "it"
+ - "iu"
+ - "ja"
+ - "jbo"
+ - "jv"
+ - "ka"
+ - "kab"
+ - "kg"
+ - "ki"
+ - "kj"
+ - "kk"
+ - "kl"
+ - "km"
+ - "kn"
+ - "ko"
+ - "kr"
+ - "ks"
+ - "ku"
+ - "kv"
+ - "kw"
+ - "ky"
+ - "la"
+ - "lb"
+ - "lfn"
+ - "lg"
+ - "li"
+ - "ln"
+ - "lo"
+ - "lt"
+ - "lu"
+ - "lv"
+ - "mg"
+ - "mh"
+ - "mi"
+ - "mk"
+ - "ml"
+ - "mn"
+ - "mr"
+ - "ms"
+ - "mt"
+ - "my"
+ - "na"
+ - "nb"
+ - "nd" # codespell:ignore nd
+ - "ne"
+ - "ng"
+ - "nl"
+ - "nn"
+ - "no"
+ - "nr"
+ - "nv"
+ - "ny"
+ - "oc"
+ - "oj"
+ - "om"
+ - "or"
+ - "os"
+ - "pa"
+ - "pi"
+ - "pl"
+ - "ps"
+ - "pt"
+ - "qu"
+ - "rm"
+ - "rn"
+ - "ro"
+ - "ru"
+ - "rw"
+ - "sa"
+ - "sc"
+ - "sco"
+ - "sd"
+ - "se"
+ - "sg"
+ - "si"
+ - "sk"
+ - "sl"
+ - "sma"
+ - "smj"
+ - "sn"
+ - "so"
+ - "sq"
+ - "sr"
+ - "ss"
+ - "st"
+ - "su"
+ - "sv"
+ - "sw"
+ - "szl"
+ - "ta"
+ - "te" # codespell:ignore te
+ - "tg"
+ - "th"
+ - "ti"
+ - "tk"
+ - "tl"
+ - "tn"
+ - "to"
+ - "tok"
+ - "tr"
+ - "ts"
+ - "tt"
+ - "tw"
+ - "ty"
+ - "ug"
+ - "uk"
+ - "ur"
+ - "uz"
+ - "ve"
+ - "vi"
+ - "vo"
+ - "wa"
+ - "wo"
+ - "xal"
+ - "xh"
+ - "yi"
+ - "yo"
+ - "za"
+ - "zgh"
+ - "zh"
+ - "zh-CN"
+ - "zh-HK"
+ - "zh-TW"
+ - "zu"
media:
selector:
text:
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index c37f9b2e941..5b8ce59fbd7 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -79,6 +79,10 @@
"name": "Content warning",
"description": "A content warning will be shown before the status text is shown (default: no content warning)."
},
+ "language": {
+ "name": "Language",
+ "description": "The language of the post (default: Mastodon account preference)."
+ },
"media": {
"name": "Media",
"description": "Attach an image or video to the post."
diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py
index ea74baab773..13556e8293c 100644
--- a/homeassistant/components/matter/binary_sensor.py
+++ b/homeassistant/components/matter/binary_sensor.py
@@ -88,6 +88,17 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterBinarySensor,
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="ThermostatOccupancySensor",
+ device_class=BinarySensorDeviceClass.OCCUPANCY,
+ # The first bit = if occupied
+ device_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(clusters.Thermostat.Attributes.Occupancy,),
+ ),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
@@ -407,6 +418,59 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DishwasherAlarm.Attributes.State,),
allow_multi=True,
),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="ValveConfigurationAndControlValveFault_GeneralFault",
+ translation_key="valve_fault_general_fault",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_to_ha=lambda x: (
+ x
+ == clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
+ ),
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(
+ clusters.ValveConfigurationAndControl.Attributes.ValveFault,
+ ),
+ allow_multi=True,
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="ValveConfigurationAndControlValveFault_Blocked",
+ translation_key="valve_fault_blocked",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_to_ha=lambda x: (
+ x
+ == clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
+ ),
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(
+ clusters.ValveConfigurationAndControl.Attributes.ValveFault,
+ ),
+ allow_multi=True,
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="ValveConfigurationAndControlValveFault_Leaking",
+ translation_key="valve_fault_leaking",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_to_ha=lambda x: (
+ x
+ == clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
+ ),
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(
+ clusters.ValveConfigurationAndControl.Attributes.ValveFault,
+ ),
+ ),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py
index df57da4ded3..4b28fe7625b 100644
--- a/homeassistant/components/matter/climate.py
+++ b/homeassistant/components/matter/climate.py
@@ -30,6 +30,7 @@ from .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
+HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
HVAC_SYSTEM_MODE_MAP = {
HVACMode.OFF: 0,
@@ -261,6 +262,18 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
+
+ self._attr_current_humidity = (
+ int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
+ if (
+ raw_measured_humidity := self.get_matter_attribute_value(
+ clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue
+ )
+ )
+ is not None
+ else None
+ )
+
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
@@ -296,24 +309,22 @@ class MatterClimate(MatterEntity, ClimateEntity):
if running_state_value := self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ThermostatRunningState
):
- match running_state_value:
- case (
- ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
- ):
- self._attr_hvac_action = HVACAction.HEATING
- case (
- ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
- ):
- self._attr_hvac_action = HVACAction.COOLING
- case (
- ThermostatRunningState.Fan
- | ThermostatRunningState.FanStage2
- | ThermostatRunningState.FanStage3
- ):
- self._attr_hvac_action = HVACAction.FAN
- case _:
- self._attr_hvac_action = HVACAction.OFF
-
+ if running_state_value & (
+ ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
+ ):
+ self._attr_hvac_action = HVACAction.HEATING
+ elif running_state_value & (
+ ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
+ ):
+ self._attr_hvac_action = HVACAction.COOLING
+ elif running_state_value & (
+ ThermostatRunningState.Fan
+ | ThermostatRunningState.FanStage2
+ | ThermostatRunningState.FanStage3
+ ):
+ self._attr_hvac_action = HVACAction.FAN
+ else:
+ self._attr_hvac_action = HVACAction.OFF
# update target temperature high/low
supports_range = (
self._attr_supported_features
@@ -430,6 +441,7 @@ DISCOVERY_SCHEMAS = [
clusters.Thermostat.Attributes.TemperatureSetpointHold,
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
+ clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
clusters.OnOff.Attributes.OnOff,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py
index 8042b7505f4..278eb8b7e83 100644
--- a/homeassistant/components/matter/discovery.py
+++ b/homeassistant/components/matter/discovery.py
@@ -78,6 +78,13 @@ def async_discover_entities(
):
continue
+ # check product_id
+ if (
+ schema.product_id is not None
+ and device_info.productID not in schema.product_id
+ ):
+ continue
+
# check product_name
if (
schema.product_name is not None
diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json
index dc1fbc25181..f21a7b7a931 100644
--- a/homeassistant/components/matter/icons.json
+++ b/homeassistant/components/matter/icons.json
@@ -148,6 +148,9 @@
},
"evse_charging_switch": {
"default": "mdi:ev-station"
+ },
+ "privacy_mode_button": {
+ "default": "mdi:shield-lock"
}
}
}
diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py
index a86938730c9..e01cc54f46d 100644
--- a/homeassistant/components/matter/light.py
+++ b/homeassistant/components/matter/light.py
@@ -477,6 +477,7 @@ DISCOVERY_SCHEMAS = [
device_types.ColorTemperatureLight,
device_types.DimmableLight,
device_types.DimmablePlugInUnit,
+ device_types.MountedDimmableLoadControl,
device_types.ExtendedColorLight,
device_types.OnOffLight,
device_types.DimmerSwitch,
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index 81de7482d46..c264ce65896 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -6,6 +6,7 @@ import asyncio
from typing import Any
from chip.clusters import Objects as clusters
+from matter_server.common.models import EventType, MatterNodeEvent
from homeassistant.components.lock import (
LockEntity,
@@ -22,6 +23,22 @@ from .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
+DOOR_LOCK_OPERATION_SOURCE = {
+ # mapping from operation source id's to textual representation
+ 0: "Unspecified",
+ 1: "Manual", # [Optional]
+ 2: "Proprietary Remote", # [Optional]
+ 3: "Keypad", # [Optional]
+ 4: "Auto", # [Optional]
+ 5: "Button", # [Optional]
+ 6: "Schedule", # [HDSCH]
+ 7: "Remote", # [M]
+ 8: "RFID", # [RID]
+ 9: "Biometric", # [USR]
+ 10: "Aliro", # [Aliro]
+}
+
+
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
@@ -41,6 +58,52 @@ class MatterLock(MatterEntity, LockEntity):
_feature_map: int | None = None
_optimistic_timer: asyncio.TimerHandle | None = None
_platform_translation_key = "lock"
+ _attr_changed_by = "Unknown"
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to events."""
+ await super().async_added_to_hass()
+ # subscribe to NodeEvent events
+ self._unsubscribes.append(
+ self.matter_client.subscribe_events(
+ callback=self._on_matter_node_event,
+ event_filter=EventType.NODE_EVENT,
+ node_filter=self._endpoint.node.node_id,
+ )
+ )
+
+ @callback
+ def _on_matter_node_event(
+ self,
+ event: EventType,
+ node_event: MatterNodeEvent,
+ ) -> None:
+ """Call on NodeEvent."""
+ if (node_event.endpoint_id != self._endpoint.endpoint_id) or (
+ node_event.cluster_id != clusters.DoorLock.id
+ ):
+ return
+
+ LOGGER.debug(
+ "Received node_event: event type %s, event id %s for %s with data %s",
+ event,
+ node_event.event_id,
+ self.entity_id,
+ node_event.data,
+ )
+
+ # handle the DoorLock events
+ node_event_data: dict[str, int] = node_event.data or {}
+ match node_event.event_id:
+ case (
+ clusters.DoorLock.Events.LockOperation.event_id
+ ): # Lock cluster event 2
+ # update the changed_by attribute to indicate lock operation source
+ operation_source: int = node_event_data.get("operationSource", -1)
+ self._attr_changed_by = DOOR_LOCK_OPERATION_SOURCE.get(
+ operation_source, "Unknown"
+ )
+ self.async_write_ha_state()
@property
def code_format(self) -> str | None:
diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py
index 4af7cc3c026..acdcc53f660 100644
--- a/homeassistant/components/matter/models.py
+++ b/homeassistant/components/matter/models.py
@@ -100,6 +100,9 @@ class MatterDiscoverySchema:
# [optional] the endpoint's vendor_id must match ANY of these values
vendor_id: tuple[int, ...] | None = None
+ # [optional] the endpoint's product_id must match ANY of these values
+ product_id: tuple[int, ...] | None = None
+
# [optional] the endpoint's product_name must match ANY of these values
product_name: tuple[str, ...] | None = None
diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py
index d2184891dc1..f9783127673 100644
--- a/homeassistant/components/matter/number.py
+++ b/homeassistant/components/matter/number.py
@@ -80,9 +80,7 @@ class MatterNumber(MatterEntity, NumberEntity):
sendvalue = int(value)
if value_convert := self.entity_description.ha_to_device:
sendvalue = value_convert(value)
- await self.write_attribute(
- value=sendvalue,
- )
+ await self.write_attribute(value=sendvalue)
@callback
def _update_from_device(self) -> None:
@@ -302,7 +300,7 @@ DISCOVERY_SCHEMAS = [
entity_description=MatterNumberEntityDescription(
key="PIROccupiedToUnoccupiedDelay",
entity_category=EntityCategory.CONFIG,
- translation_key="pir_occupied_to_unoccupied_delay",
+ translation_key="hold_time", # pir_occupied_to_unoccupied_delay for old revisions
native_max_value=65534,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -312,6 +310,38 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.OccupancySensing.Attributes.PIROccupiedToUnoccupiedDelay,
),
+ absent_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterNumberEntityDescription(
+ key="OccupancySensingHoldTime",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="hold_time",
+ native_max_value=65534,
+ native_min_value=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterNumber,
+ required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterNumberEntityDescription(
+ key="ValveConfigurationAndControlDefaultOpenDuration",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="valve_configuration_and_control_default_open_duration",
+ native_max_value=65534,
+ native_min_value=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterNumber,
+ required_attributes=(
+ clusters.ValveConfigurationAndControl.Attributes.DefaultOpenDuration,
+ ),
+ allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
@@ -405,4 +435,35 @@ DISCOVERY_SCHEMAS = [
custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn,
),
),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterNumberEntityDescription(
+ key="DoorLockWrongCodeEntryLimit",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="wrong_code_entry_limit",
+ native_max_value=255,
+ native_min_value=1,
+ native_step=1,
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterNumber,
+ required_attributes=(clusters.DoorLock.Attributes.WrongCodeEntryLimit,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.NUMBER,
+ entity_description=MatterNumberEntityDescription(
+ key="DoorLockUserCodeTemporaryDisableTime",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="user_code_temporary_disable_time",
+ native_max_value=255,
+ native_min_value=1,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ mode=NumberMode.BOX,
+ ),
+ entity_class=MatterNumber,
+ required_attributes=(
+ clusters.DoorLock.Attributes.UserCodeTemporaryDisableTime,
+ ),
+ ),
]
diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py
index 5d7a5363da0..665e9041be7 100644
--- a/homeassistant/components/matter/select.py
+++ b/homeassistant/components/matter/select.py
@@ -502,4 +502,29 @@ DISCOVERY_SCHEMAS = [
clusters.PumpConfigurationAndControl.Attributes.OperationMode,
),
),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterSelectEntityDescription(
+ key="AqaraBooleanStateConfigurationCurrentSensitivityLevel",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="sensitivity_level",
+ options=["10 mm", "20 mm", "30 mm"],
+ device_to_ha={
+ 0: "10 mm", # 10 mm => CurrentSensitivityLevel=0 / highest sensitivity level
+ 1: "20 mm", # 20 mm => CurrentSensitivityLevel=1 / medium sensitivity level
+ 2: "30 mm", # 30 mm => CurrentSensitivityLevel=2 / lowest sensitivity level
+ }.get,
+ ha_to_device={
+ "10 mm": 0,
+ "20 mm": 1,
+ "30 mm": 2,
+ }.get,
+ ),
+ entity_class=MatterAttributeSelectEntity,
+ required_attributes=(
+ clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel,
+ ),
+ vendor_id=(4447,),
+ product_id=(8194,),
+ ),
]
diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py
index 18bd7f84da3..0c95cda9474 100644
--- a/homeassistant/components/matter/sensor.py
+++ b/homeassistant/components/matter/sensor.py
@@ -152,6 +152,8 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
+TEMPERATURE_SCALING_FACTOR = 100
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -349,6 +351,7 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
),
+ allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
@@ -634,8 +637,8 @@ DISCOVERY_SCHEMAS = [
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="NitrogenDioxideSensor",
+ translation_key="nitrogen_dioxide",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
- device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
@@ -1141,6 +1144,23 @@ DISCOVERY_SCHEMAS = [
device_type=(device_types.Thermostat,),
allow_multi=True, # also used for climate entity
),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ThermostatOutdoorTemperature",
+ translation_key="outdoor_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ device_to_ha=lambda x: (
+ None if x is None else x / TEMPERATURE_SCALING_FACTOR
+ ),
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.Thermostat.Attributes.OutdoorTemperature,),
+ device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
+ ),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterOperationalStateSensorEntityDescription(
@@ -1370,4 +1390,31 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.PumpConfigurationAndControl.Attributes.Speed,),
),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ValveConfigurationAndControlAutoCloseTime",
+ translation_key="auto_close_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ state_class=None,
+ device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
+ ),
+ entity_class=MatterSensor,
+ featuremap_contains=clusters.ValveConfigurationAndControl.Bitmaps.Feature.kTimeSync,
+ required_attributes=(
+ clusters.ValveConfigurationAndControl.Attributes.AutoCloseTime,
+ ),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="ServiceAreaEstimatedEndTime",
+ translation_key="estimated_end_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ state_class=None,
+ device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
+ ),
]
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index e9c023cd74e..a46fbddd612 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -94,6 +94,15 @@
},
"alarm_door": {
"name": "Door alarm"
+ },
+ "valve_fault_blocked": {
+ "name": "Valve blocked"
+ },
+ "valve_fault_general_fault": {
+ "name": "General fault"
+ },
+ "valve_fault_leaking": {
+ "name": "Valve leaking"
}
},
"button": {
@@ -184,19 +193,22 @@
"name": "Altitude above sea level"
},
"cook_time": {
- "name": "Cook time"
+ "name": "Cooking time"
},
"pump_setpoint": {
"name": "Setpoint"
},
+ "user_code_temporary_disable_time": {
+ "name": "User code temporary disable time"
+ },
"temperature_offset": {
"name": "Temperature offset"
},
"temperature_setpoint": {
"name": "Temperature setpoint"
},
- "pir_occupied_to_unoccupied_delay": {
- "name": "Occupied to unoccupied delay"
+ "hold_time": {
+ "name": "Hold time"
},
"auto_relock_timer": {
"name": "Autorelock time"
@@ -206,6 +218,12 @@
},
"led_indicator_intensity_on": {
"name": "LED on intensity"
+ },
+ "valve_configuration_and_control_default_open_duration": {
+ "name": "Default open duration"
+ },
+ "wrong_code_entry_limit": {
+ "name": "Wrong code limit"
}
},
"light": {
@@ -292,6 +310,9 @@
"activated_carbon_filter_condition": {
"name": "Activated carbon filter condition"
},
+ "auto_close_time": {
+ "name": "Auto-close time"
+ },
"contamination_state": {
"name": "Contamination state",
"state": {
@@ -420,6 +441,9 @@
"evse_soc": {
"name": "State of charge"
},
+ "nitrogen_dioxide": {
+ "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
+ },
"pump_control_mode": {
"name": "Control mode",
"state": {
@@ -467,6 +491,9 @@
"apparent_current": {
"name": "Apparent current"
},
+ "outdoor_temperature": {
+ "name": "Outdoor temperature"
+ },
"reactive_current": {
"name": "Reactive current"
},
@@ -492,6 +519,9 @@
},
"evse_charging_switch": {
"name": "Enable charging"
+ },
+ "privacy_mode_button": {
+ "name": "Privacy mode button"
}
},
"vacuum": {
diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py
index df8581c5c4f..2c02522f0a1 100644
--- a/homeassistant/components/matter/switch.py
+++ b/homeassistant/components/matter/switch.py
@@ -263,6 +263,18 @@ DISCOVERY_SCHEMAS = [
),
vendor_id=(4874,),
),
+ MatterDiscoverySchema(
+ platform=Platform.SWITCH,
+ entity_description=MatterNumericSwitchEntityDescription(
+ key="DoorLockEnablePrivacyModeButton",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="privacy_mode_button",
+ device_to_ha=bool,
+ ha_to_device=int,
+ ),
+ entity_class=MatterNumericSwitch,
+ required_attributes=(clusters.DoorLock.Attributes.EnablePrivacyModeButton,),
+ ),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterGenericCommandSwitchEntityDescription(
diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py
index 4cedec74bf2..715cdc2a09e 100644
--- a/homeassistant/components/matter/valve.py
+++ b/homeassistant/components/matter/valve.py
@@ -21,7 +21,6 @@ from .helpers import get_matter
from .models import MatterDiscoverySchema
ValveConfigurationAndControl = clusters.ValveConfigurationAndControl
-
ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum
diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py
index f560875292f..df4ec2c0f2f 100644
--- a/homeassistant/components/mcp/coordinator.py
+++ b/homeassistant/components/mcp/coordinator.py
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
TIMEOUT = 10
-TokenManager = Callable[[], Awaitable[str]]
+type TokenManager = Callable[[], Awaitable[str]]
@asynccontextmanager
diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json
index 7ff64d29aa4..dfc180f7022 100644
--- a/homeassistant/components/mcp/manifest.json
+++ b/homeassistant/components/mcp/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mcp",
"iot_class": "local_polling",
"quality_scale": "silver",
- "requirements": ["mcp==1.5.0"]
+ "requirements": ["mcp==1.14.1"]
}
diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json
index 780b4818666..5614609ecd4 100644
--- a/homeassistant/components/mcp/strings.json
+++ b/homeassistant/components/mcp/strings.json
@@ -9,6 +9,18 @@
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
}
},
+ "credentials_choice": {
+ "title": "Choose how to authenticate with the MCP server",
+ "description": "You can either use existing credentials from another integration or set up new credentials.",
+ "menu_options": {
+ "new_credentials": "Set up new credentials",
+ "pick_implementation": "Use existing credentials"
+ },
+ "menu_option_descriptions": {
+ "new_credentials": "You will be guided through setting up a new OAuth Client ID and secret.",
+ "pick_implementation": "You may use previously entered OAuth credentials."
+ }
+ },
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
"data": {
@@ -27,14 +39,21 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
}
}
}
diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py
index 07284b29434..3746705510b 100644
--- a/homeassistant/components/mcp_server/http.py
+++ b/homeassistant/components/mcp_server/http.py
@@ -1,7 +1,16 @@
-"""Model Context Protocol transport protocol for Server Sent Events (SSE).
+"""Model Context Protocol transport protocol for Streamable HTTP and SSE.
-This registers HTTP endpoints that supports SSE as a transport layer
-for the Model Context Protocol. There are two HTTP endpoints:
+This registers HTTP endpoints that support the Streamable HTTP protocol as
+well as the older SSE as a transport layer.
+
+The Streamable HTTP protocol uses a single HTTP endpoint:
+
+- /api/mcp_server: The Streamable HTTP endpoint currently implements the
+ stateless protocol for simplicity. This receives client requests and
+ sends them to the MCP server, then waits for a response to send back to
+ the client.
+
+The older SSE protocol has two HTTP endpoints:
- /mcp_server/sse: The SSE endpoint that is used to establish a session
with the client and glue to the MCP server. This is used to push responses
@@ -14,6 +23,9 @@ for the Model Context Protocol. There are two HTTP endpoints:
See https://modelcontextprotocol.io/docs/concepts/transports
"""
+import asyncio
+from dataclasses import dataclass
+from http import HTTPStatus
import logging
from aiohttp import web
@@ -21,12 +33,14 @@ from aiohttp.web_exceptions import HTTPBadRequest, HTTPNotFound
from aiohttp_sse import sse_response
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
-from mcp import types
+from mcp import JSONRPCRequest, types
+from mcp.server import InitializationOptions, Server
+from mcp.shared.message import SessionMessage
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.const import CONF_LLM_HASS_API
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import llm
from .const import DOMAIN
@@ -36,6 +50,14 @@ from .types import MCPServerConfigEntry
_LOGGER = logging.getLogger(__name__)
+# Streamable HTTP endpoint
+STREAMABLE_API = f"/api/{DOMAIN}"
+TIMEOUT = 60 # Seconds
+
+# Content types
+CONTENT_TYPE_JSON = "application/json"
+
+# Legacy SSE endpoint
SSE_API = f"/{DOMAIN}/sse"
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
@@ -45,6 +67,7 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the websocket API."""
hass.http.register_view(ModelContextProtocolSSEView())
hass.http.register_view(ModelContextProtocolMessagesView())
+ hass.http.register_view(ModelContextProtocolStreamableView())
def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
@@ -65,6 +88,52 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
return config_entries[0]
+@dataclass
+class Streams:
+ """Pairs of streams for MCP server communication."""
+
+ # The MCP server reads from the read stream. The HTTP handler receives
+ # incoming client messages and writes the to the read_stream_writer.
+ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
+ read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
+
+ # The MCP server writes to the write stream. The HTTP handler reads from
+ # the write stream and sends messages to the client.
+ write_stream: MemoryObjectSendStream[SessionMessage]
+ write_stream_reader: MemoryObjectReceiveStream[SessionMessage]
+
+
+def create_streams() -> Streams:
+ """Create a new pair of streams for MCP server communication."""
+ read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
+ write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
+ return Streams(
+ read_stream=read_stream,
+ read_stream_writer=read_stream_writer,
+ write_stream=write_stream,
+ write_stream_reader=write_stream_reader,
+ )
+
+
+async def create_mcp_server(
+ hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry
+) -> tuple[Server, InitializationOptions]:
+ """Initialize the MCP server to ensure it's ready to handle requests."""
+ llm_context = llm.LLMContext(
+ platform=DOMAIN,
+ context=context,
+ language="*",
+ assistant=conversation.DOMAIN,
+ device_id=None,
+ )
+ llm_api_id = entry.data[CONF_LLM_HASS_API]
+ server = await create_server(hass, llm_api_id, llm_context)
+ options = await hass.async_add_executor_job(
+ server.create_initialization_options # Reads package for version info
+ )
+ return server, options
+
+
class ModelContextProtocolSSEView(HomeAssistantView):
"""Model Context Protocol SSE endpoint."""
@@ -85,30 +154,12 @@ class ModelContextProtocolSSEView(HomeAssistantView):
entry = async_get_config_entry(hass)
session_manager = entry.runtime_data
- context = llm.LLMContext(
- platform=DOMAIN,
- context=self.context(request),
- language="*",
- assistant=conversation.DOMAIN,
- device_id=None,
- )
- llm_api_id = entry.data[CONF_LLM_HASS_API]
- server = await create_server(hass, llm_api_id, context)
- options = await hass.async_add_executor_job(
- server.create_initialization_options # Reads package for version info
- )
-
- read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception]
- read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception]
- read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
-
- write_stream: MemoryObjectSendStream[types.JSONRPCMessage]
- write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage]
- write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
+ server, options = await create_mcp_server(hass, self.context(request), entry)
+ streams = create_streams()
async with (
sse_response(request) as response,
- session_manager.create(Session(read_stream_writer)) as session_id,
+ session_manager.create(Session(streams.read_stream_writer)) as session_id,
):
session_uri = MESSAGES_API.format(session_id=session_id)
_LOGGER.debug("Sending SSE endpoint: %s", session_uri)
@@ -116,17 +167,20 @@ class ModelContextProtocolSSEView(HomeAssistantView):
async def sse_reader() -> None:
"""Forward MCP server responses to the client."""
- async for message in write_stream_reader:
- _LOGGER.debug("Sending SSE message: %s", message)
+ async for session_message in streams.write_stream_reader:
+ _LOGGER.debug("Sending SSE message: %s", session_message)
await response.send(
- message.model_dump_json(by_alias=True, exclude_none=True),
+ session_message.message.model_dump_json(
+ by_alias=True, exclude_none=True
+ ),
event="message",
)
async with anyio.create_task_group() as tg:
tg.start_soon(sse_reader)
- await server.run(read_stream, write_stream, options)
- return response
+ await server.run(streams.read_stream, streams.write_stream, options)
+
+ return response
class ModelContextProtocolMessagesView(HomeAssistantView):
@@ -162,5 +216,66 @@ class ModelContextProtocolMessagesView(HomeAssistantView):
raise HTTPBadRequest(text="Could not parse message") from err
_LOGGER.debug("Received client message: %s", message)
- await session.read_stream_writer.send(message)
+ await session.read_stream_writer.send(SessionMessage(message))
return web.Response(status=200)
+
+
+class ModelContextProtocolStreamableView(HomeAssistantView):
+ """Model Context Protocol Streamable HTTP endpoint."""
+
+ name = f"{DOMAIN}:streamable"
+ url = STREAMABLE_API
+
+ async def get(self, request: web.Request) -> web.StreamResponse:
+ """Handle unsupported methods."""
+ return web.Response(
+ status=HTTPStatus.METHOD_NOT_ALLOWED, text="Only POST method is supported"
+ )
+
+ async def post(self, request: web.Request) -> web.StreamResponse:
+ """Process JSON-RPC messages for the Model Context Protocol."""
+ hass = request.app[KEY_HASS]
+ entry = async_get_config_entry(hass)
+
+ # The request must include a JSON-RPC message
+ if CONTENT_TYPE_JSON not in request.headers.get("accept", ""):
+ raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}")
+ if request.content_type != CONTENT_TYPE_JSON:
+ raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}")
+ try:
+ json_data = await request.json()
+ message = types.JSONRPCMessage.model_validate(json_data)
+ except ValueError as err:
+ _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err)
+ raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err
+
+ _LOGGER.debug("Received client message: %s", message)
+
+ # For notifications and responses only, return 202 Accepted
+ if not isinstance(message.root, JSONRPCRequest):
+ _LOGGER.debug("Notification or response received, returning 202")
+ return web.Response(status=HTTPStatus.ACCEPTED)
+
+ # The MCP server runs as a background task for the duration of the
+ # request. We open a buffered stream pair to communicate with it. The
+ # request is sent to the MCP server and we wait for a single response
+ # then shut down the server.
+ server, options = await create_mcp_server(hass, self.context(request), entry)
+ streams = create_streams()
+
+ async def run_server() -> None:
+ await server.run(
+ streams.read_stream, streams.write_stream, options, stateless=True
+ )
+
+ async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg:
+ tg.start_soon(run_server)
+
+ await streams.read_stream_writer.send(SessionMessage(message))
+ session_message = await anext(streams.write_stream_reader)
+ tg.cancel_scope.cancel()
+
+ _LOGGER.debug("Sending response: %s", session_message)
+ return web.json_response(
+ data=session_message.message.model_dump(by_alias=True, exclude_none=True),
+ )
diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json
index b5fb1bdcd87..abc43ffffeb 100644
--- a/homeassistant/components/mcp_server/manifest.json
+++ b/homeassistant/components/mcp_server/manifest.json
@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "silver",
- "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.9.0"],
+ "requirements": ["mcp==1.14.1", "aiohttp_sse==2.2.0", "anyio==4.10.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py
index 953fc1314da..85bcd407fef 100644
--- a/homeassistant/components/mcp_server/server.py
+++ b/homeassistant/components/mcp_server/server.py
@@ -96,7 +96,7 @@ async def create_server(
llm_api = await get_api_instance()
return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
- @server.call_tool() # type: ignore[no-untyped-call, misc]
+ @server.call_tool() # type: ignore[misc]
async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
"""Handle calling tools."""
llm_api = await get_api_instance()
diff --git a/homeassistant/components/mcp_server/session.py b/homeassistant/components/mcp_server/session.py
index 4c586fd32a0..e4bfe25eaf5 100644
--- a/homeassistant/components/mcp_server/session.py
+++ b/homeassistant/components/mcp_server/session.py
@@ -11,7 +11,7 @@ from dataclasses import dataclass
import logging
from anyio.streams.memory import MemoryObjectSendStream
-from mcp import types
+from mcp.shared.message import SessionMessage
from homeassistant.util import ulid as ulid_util
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
class Session:
"""A session for the Model Context Protocol."""
- read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception]
+ read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
class SessionManager:
diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py
index 0221fd45051..e5ee1bc9e99 100644
--- a/homeassistant/components/mealie/__init__.py
+++ b/homeassistant/components/mealie/__init__.py
@@ -48,7 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo
),
)
try:
- await client.define_household_support()
about = await client.get_about()
version = create_version(about.version)
except MealieAuthenticationError as error:
diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py
index 2addd23284e..25e46ec6262 100644
--- a/homeassistant/components/mealie/config_flow.py
+++ b/homeassistant/components/mealie/config_flow.py
@@ -7,8 +7,9 @@ from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionE
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
+from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_PORT, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION
from .utils import create_version
@@ -25,13 +26,21 @@ REAUTH_SCHEMA = vol.Schema(
vol.Required(CONF_API_TOKEN): str,
}
)
+DISCOVERY_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_TOKEN): str,
+ }
+)
class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Mealie config flow."""
+ VERSION = 1
+
host: str | None = None
verify_ssl: bool = True
+ _hassio_discovery: dict[str, Any] | None = None
async def check_connection(
self, api_token: str
@@ -143,3 +152,59 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=USER_SCHEMA,
errors=errors,
)
+
+ async def async_step_hassio(
+ self, discovery_info: HassioServiceInfo
+ ) -> ConfigFlowResult:
+ """Prepare configuration for a Mealie add-on.
+
+ This flow is triggered by the discovery component.
+ """
+ await self._async_handle_discovery_without_unique_id()
+
+ self._hassio_discovery = discovery_info.config
+
+ return await self.async_step_hassio_confirm()
+
+ async def async_step_hassio_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm Supervisor discovery and prompt for API token."""
+ if user_input is None:
+ return await self._show_hassio_form()
+
+ assert self._hassio_discovery
+
+ self.host = (
+ f"{self._hassio_discovery[CONF_HOST]}:{self._hassio_discovery[CONF_PORT]}"
+ )
+ self.verify_ssl = True
+
+ errors, user_id = await self.check_connection(
+ user_input[CONF_API_TOKEN],
+ )
+
+ if not errors:
+ await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title="Mealie",
+ data={
+ CONF_HOST: self.host,
+ CONF_API_TOKEN: user_input[CONF_API_TOKEN],
+ CONF_VERIFY_SSL: self.verify_ssl,
+ },
+ )
+ return await self._show_hassio_form(errors)
+
+ async def _show_hassio_form(
+ self, errors: dict[str, str] | None = None
+ ) -> ConfigFlowResult:
+ """Show the Hass.io confirmation form to the user."""
+ assert self._hassio_discovery
+ return self.async_show_form(
+ step_id="hassio_confirm",
+ data_schema=DISCOVERY_SCHEMA,
+ description_placeholders={"addon": self._hassio_discovery["addon"]},
+ errors=errors or {},
+ )
diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py
index e729265bcbc..4f8c4773b9e 100644
--- a/homeassistant/components/mealie/const.py
+++ b/homeassistant/components/mealie/const.py
@@ -19,4 +19,4 @@ ATTR_NOTE_TEXT = "note_text"
ATTR_SEARCH_TERMS = "search_terms"
ATTR_RESULT_LIMIT = "result_limit"
-MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v1.0.0")
+MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v2.0.0")
diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json
index a744b9e6ced..1fdcc4f897f 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": "silver",
- "requirements": ["aiomealie==0.10.1"]
+ "requirements": ["aiomealie==1.0.0"]
}
diff --git a/homeassistant/components/mealie/quality_scale.yaml b/homeassistant/components/mealie/quality_scale.yaml
index 738c5b99d91..1fccc3add81 100644
--- a/homeassistant/components/mealie/quality_scale.yaml
+++ b/homeassistant/components/mealie/quality_scale.yaml
@@ -39,12 +39,18 @@ rules:
# Gold
devices: done
diagnostics: done
- discovery-update-info: todo
- discovery: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration will only discover a Mealie addon that is local, not on the network.
+ discovery:
+ status: done
+ comment: |
+ The integration will discover a Mealie addon posting a discovery message.
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
- docs-supported-devices: todo
+ docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json
index 5533631f755..8e51da6d7d1 100644
--- a/homeassistant/components/mealie/strings.json
+++ b/homeassistant/components/mealie/strings.json
@@ -39,6 +39,16 @@
"api_token": "[%key:component::mealie::common::data_description_api_token%]",
"verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]"
}
+ },
+ "hassio_confirm": {
+ "title": "Mealie via Home Assistant add-on",
+ "description": "Do you want to configure Home Assistant to connect to the Mealie instance provided by the add-on: {addon}?",
+ "data": {
+ "api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "[%key:component::mealie::common::data_description_api_token%]"
+ }
}
},
"error": {
@@ -50,6 +60,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "You have to use the same account that was used to configure the integration."
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index 477e77022de..35977da9924 100644
--- a/homeassistant/components/media_extractor/manifest.json
+++ b/homeassistant/components/media_extractor/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
- "requirements": ["yt-dlp[default]==2025.08.11"],
+ "requirements": ["yt-dlp[default]==2025.09.26"],
"single_config_entry": true
}
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index b2cb7d76e8f..da773a7eb29 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -55,12 +55,6 @@ from homeassistant.const import ( # noqa: F401
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.network import get_url
@@ -75,26 +69,6 @@ from .browse_media import ( # noqa: F401
async_process_play_media_url,
)
from .const import ( # noqa: F401
- _DEPRECATED_MEDIA_CLASS_DIRECTORY,
- _DEPRECATED_SUPPORT_BROWSE_MEDIA,
- _DEPRECATED_SUPPORT_CLEAR_PLAYLIST,
- _DEPRECATED_SUPPORT_GROUPING,
- _DEPRECATED_SUPPORT_NEXT_TRACK,
- _DEPRECATED_SUPPORT_PAUSE,
- _DEPRECATED_SUPPORT_PLAY,
- _DEPRECATED_SUPPORT_PLAY_MEDIA,
- _DEPRECATED_SUPPORT_PREVIOUS_TRACK,
- _DEPRECATED_SUPPORT_REPEAT_SET,
- _DEPRECATED_SUPPORT_SEEK,
- _DEPRECATED_SUPPORT_SELECT_SOUND_MODE,
- _DEPRECATED_SUPPORT_SELECT_SOURCE,
- _DEPRECATED_SUPPORT_SHUFFLE_SET,
- _DEPRECATED_SUPPORT_STOP,
- _DEPRECATED_SUPPORT_TURN_OFF,
- _DEPRECATED_SUPPORT_TURN_ON,
- _DEPRECATED_SUPPORT_VOLUME_MUTE,
- _DEPRECATED_SUPPORT_VOLUME_SET,
- _DEPRECATED_SUPPORT_VOLUME_STEP,
ATTR_APP_ID,
ATTR_APP_NAME,
ATTR_ENTITY_PICTURE_LOCAL,
@@ -161,6 +135,8 @@ CACHE_LOCK: Final = "lock"
CACHE_URL: Final = "url"
CACHE_CONTENT: Final = "content"
+ATTR_MEDIA = "media"
+
class MediaPlayerEnqueue(StrEnum):
"""Enqueue types for playing media."""
@@ -186,20 +162,27 @@ class MediaPlayerDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the MediaPlayerDeviceClass enum instead.
-_DEPRECATED_DEVICE_CLASS_TV = DeprecatedConstantEnum(
- MediaPlayerDeviceClass.TV, "2025.10"
-)
-_DEPRECATED_DEVICE_CLASS_SPEAKER = DeprecatedConstantEnum(
- MediaPlayerDeviceClass.SPEAKER, "2025.10"
-)
-_DEPRECATED_DEVICE_CLASS_RECEIVER = DeprecatedConstantEnum(
- MediaPlayerDeviceClass.RECEIVER, "2025.10"
-)
DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass]
+def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]:
+ """If 'media' key exists, promote its fields to the top level."""
+ if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict):
+ if ATTR_MEDIA_CONTENT_TYPE in data or ATTR_MEDIA_CONTENT_ID in data:
+ raise vol.Invalid(
+ f"Play media cannot contain '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}' or '{ATTR_MEDIA_CONTENT_TYPE}'"
+ )
+ media_data = data[ATTR_MEDIA]
+
+ if ATTR_MEDIA_CONTENT_TYPE in media_data:
+ data[ATTR_MEDIA_CONTENT_TYPE] = media_data[ATTR_MEDIA_CONTENT_TYPE]
+ if ATTR_MEDIA_CONTENT_ID in media_data:
+ data[ATTR_MEDIA_CONTENT_ID] = media_data[ATTR_MEDIA_CONTENT_ID]
+
+ del data[ATTR_MEDIA]
+ return data
+
+
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = {
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
@@ -436,6 +419,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_PLAY_MEDIA,
vol.All(
+ _promote_media_fields,
cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA),
_rewrite_enqueue,
_rename_keys(
@@ -1175,6 +1159,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
media_content_id: str | None = None,
media_filter_classes: list[MediaClass] | None = None,
) -> SearchMedia:
+ """Search for media."""
return await self.async_search_media(
query=SearchMediaQuery(
search_query=search_query,
@@ -1489,13 +1474,3 @@ async def async_fetch_image(
logger.warning("Error retrieving proxied image from %s", url)
return content, content_type
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py
index f842ccccb65..990acb4c497 100644
--- a/homeassistant/components/media_player/const.py
+++ b/homeassistant/components/media_player/const.py
@@ -1,15 +1,8 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- EnumWithDeprecatedMembers,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
+from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers
# How long our auth signature on the content should be valid for
CONTENT_AUTH_EXPIRY_TIME = 3600 * 24
@@ -94,38 +87,6 @@ class MediaClass(StrEnum):
VIDEO = "video"
-# These MEDIA_CLASS_* constants are deprecated as of Home Assistant 2022.10.
-# Please use the MediaClass enum instead.
-_DEPRECATED_MEDIA_CLASS_ALBUM = DeprecatedConstantEnum(MediaClass.ALBUM, "2025.10")
-_DEPRECATED_MEDIA_CLASS_APP = DeprecatedConstantEnum(MediaClass.APP, "2025.10")
-_DEPRECATED_MEDIA_CLASS_ARTIST = DeprecatedConstantEnum(MediaClass.ARTIST, "2025.10")
-_DEPRECATED_MEDIA_CLASS_CHANNEL = DeprecatedConstantEnum(MediaClass.CHANNEL, "2025.10")
-_DEPRECATED_MEDIA_CLASS_COMPOSER = DeprecatedConstantEnum(
- MediaClass.COMPOSER, "2025.10"
-)
-_DEPRECATED_MEDIA_CLASS_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
- MediaClass.CONTRIBUTING_ARTIST, "2025.10"
-)
-_DEPRECATED_MEDIA_CLASS_DIRECTORY = DeprecatedConstantEnum(
- MediaClass.DIRECTORY, "2025.10"
-)
-_DEPRECATED_MEDIA_CLASS_EPISODE = DeprecatedConstantEnum(MediaClass.EPISODE, "2025.10")
-_DEPRECATED_MEDIA_CLASS_GAME = DeprecatedConstantEnum(MediaClass.GAME, "2025.10")
-_DEPRECATED_MEDIA_CLASS_GENRE = DeprecatedConstantEnum(MediaClass.GENRE, "2025.10")
-_DEPRECATED_MEDIA_CLASS_IMAGE = DeprecatedConstantEnum(MediaClass.IMAGE, "2025.10")
-_DEPRECATED_MEDIA_CLASS_MOVIE = DeprecatedConstantEnum(MediaClass.MOVIE, "2025.10")
-_DEPRECATED_MEDIA_CLASS_MUSIC = DeprecatedConstantEnum(MediaClass.MUSIC, "2025.10")
-_DEPRECATED_MEDIA_CLASS_PLAYLIST = DeprecatedConstantEnum(
- MediaClass.PLAYLIST, "2025.10"
-)
-_DEPRECATED_MEDIA_CLASS_PODCAST = DeprecatedConstantEnum(MediaClass.PODCAST, "2025.10")
-_DEPRECATED_MEDIA_CLASS_SEASON = DeprecatedConstantEnum(MediaClass.SEASON, "2025.10")
-_DEPRECATED_MEDIA_CLASS_TRACK = DeprecatedConstantEnum(MediaClass.TRACK, "2025.10")
-_DEPRECATED_MEDIA_CLASS_TV_SHOW = DeprecatedConstantEnum(MediaClass.TV_SHOW, "2025.10")
-_DEPRECATED_MEDIA_CLASS_URL = DeprecatedConstantEnum(MediaClass.URL, "2025.10")
-_DEPRECATED_MEDIA_CLASS_VIDEO = DeprecatedConstantEnum(MediaClass.VIDEO, "2025.10")
-
-
class MediaType(StrEnum):
"""Media type for media player entities."""
@@ -152,33 +113,6 @@ class MediaType(StrEnum):
VIDEO = "video"
-# These MEDIA_TYPE_* constants are deprecated as of Home Assistant 2022.10.
-# Please use the MediaType enum instead.
-_DEPRECATED_MEDIA_TYPE_ALBUM = DeprecatedConstantEnum(MediaType.ALBUM, "2025.10")
-_DEPRECATED_MEDIA_TYPE_APP = DeprecatedConstantEnum(MediaType.APP, "2025.10")
-_DEPRECATED_MEDIA_TYPE_APPS = DeprecatedConstantEnum(MediaType.APPS, "2025.10")
-_DEPRECATED_MEDIA_TYPE_ARTIST = DeprecatedConstantEnum(MediaType.ARTIST, "2025.10")
-_DEPRECATED_MEDIA_TYPE_CHANNEL = DeprecatedConstantEnum(MediaType.CHANNEL, "2025.10")
-_DEPRECATED_MEDIA_TYPE_CHANNELS = DeprecatedConstantEnum(MediaType.CHANNELS, "2025.10")
-_DEPRECATED_MEDIA_TYPE_COMPOSER = DeprecatedConstantEnum(MediaType.COMPOSER, "2025.10")
-_DEPRECATED_MEDIA_TYPE_CONTRIBUTING_ARTIST = DeprecatedConstantEnum(
- MediaType.CONTRIBUTING_ARTIST, "2025.10"
-)
-_DEPRECATED_MEDIA_TYPE_EPISODE = DeprecatedConstantEnum(MediaType.EPISODE, "2025.10")
-_DEPRECATED_MEDIA_TYPE_GAME = DeprecatedConstantEnum(MediaType.GAME, "2025.10")
-_DEPRECATED_MEDIA_TYPE_GENRE = DeprecatedConstantEnum(MediaType.GENRE, "2025.10")
-_DEPRECATED_MEDIA_TYPE_IMAGE = DeprecatedConstantEnum(MediaType.IMAGE, "2025.10")
-_DEPRECATED_MEDIA_TYPE_MOVIE = DeprecatedConstantEnum(MediaType.MOVIE, "2025.10")
-_DEPRECATED_MEDIA_TYPE_MUSIC = DeprecatedConstantEnum(MediaType.MUSIC, "2025.10")
-_DEPRECATED_MEDIA_TYPE_PLAYLIST = DeprecatedConstantEnum(MediaType.PLAYLIST, "2025.10")
-_DEPRECATED_MEDIA_TYPE_PODCAST = DeprecatedConstantEnum(MediaType.PODCAST, "2025.10")
-_DEPRECATED_MEDIA_TYPE_SEASON = DeprecatedConstantEnum(MediaType.SEASON, "2025.10")
-_DEPRECATED_MEDIA_TYPE_TRACK = DeprecatedConstantEnum(MediaType.TRACK, "2025.10")
-_DEPRECATED_MEDIA_TYPE_TVSHOW = DeprecatedConstantEnum(MediaType.TVSHOW, "2025.10")
-_DEPRECATED_MEDIA_TYPE_URL = DeprecatedConstantEnum(MediaType.URL, "2025.10")
-_DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10")
-
-
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media"
@@ -197,11 +131,6 @@ class RepeatMode(StrEnum):
ONE = "one"
-# These REPEAT_MODE_* constants are deprecated as of Home Assistant 2022.10.
-# Please use the RepeatMode enum instead.
-_DEPRECATED_REPEAT_MODE_ALL = DeprecatedConstantEnum(RepeatMode.ALL, "2025.10")
-_DEPRECATED_REPEAT_MODE_OFF = DeprecatedConstantEnum(RepeatMode.OFF, "2025.10")
-_DEPRECATED_REPEAT_MODE_ONE = DeprecatedConstantEnum(RepeatMode.ONE, "2025.10")
REPEAT_MODES = [cls.value for cls in RepeatMode]
@@ -231,71 +160,3 @@ class MediaPlayerEntityFeature(IntFlag):
MEDIA_ANNOUNCE = 1048576
MEDIA_ENQUEUE = 2097152
SEARCH_MEDIA = 4194304
-
-
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the MediaPlayerEntityFeature enum instead.
-_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.PAUSE, "2025.10"
-)
-_DEPRECATED_SUPPORT_SEEK = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.SEEK, "2025.10"
-)
-_DEPRECATED_SUPPORT_VOLUME_SET = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.VOLUME_SET, "2025.10"
-)
-_DEPRECATED_SUPPORT_VOLUME_MUTE = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.VOLUME_MUTE, "2025.10"
-)
-_DEPRECATED_SUPPORT_PREVIOUS_TRACK = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.PREVIOUS_TRACK, "2025.10"
-)
-_DEPRECATED_SUPPORT_NEXT_TRACK = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.NEXT_TRACK, "2025.10"
-)
-_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.TURN_ON, "2025.10"
-)
-_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.TURN_OFF, "2025.10"
-)
-_DEPRECATED_SUPPORT_PLAY_MEDIA = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.PLAY_MEDIA, "2025.10"
-)
-_DEPRECATED_SUPPORT_VOLUME_STEP = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.VOLUME_STEP, "2025.10"
-)
-_DEPRECATED_SUPPORT_SELECT_SOURCE = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.SELECT_SOURCE, "2025.10"
-)
-_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.STOP, "2025.10"
-)
-_DEPRECATED_SUPPORT_CLEAR_PLAYLIST = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.CLEAR_PLAYLIST, "2025.10"
-)
-_DEPRECATED_SUPPORT_PLAY = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.PLAY, "2025.10"
-)
-_DEPRECATED_SUPPORT_SHUFFLE_SET = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.SHUFFLE_SET, "2025.10"
-)
-_DEPRECATED_SUPPORT_SELECT_SOUND_MODE = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.SELECT_SOUND_MODE, "2025.10"
-)
-_DEPRECATED_SUPPORT_BROWSE_MEDIA = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.BROWSE_MEDIA, "2025.10"
-)
-_DEPRECATED_SUPPORT_REPEAT_SET = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.REPEAT_SET, "2025.10"
-)
-_DEPRECATED_SUPPORT_GROUPING = DeprecatedConstantEnum(
- MediaPlayerEntityFeature.GROUPING, "2025.10"
-)
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py
index 9b714fdf52d..2cca51af4ad 100644
--- a/homeassistant/components/media_player/intent.py
+++ b/homeassistant/components/media_player/intent.py
@@ -14,6 +14,7 @@ from homeassistant.const import (
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
+ SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
STATE_PLAYING,
)
@@ -27,6 +28,7 @@ from .browse_media import SearchMedia
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED,
DOMAIN,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
@@ -39,6 +41,8 @@ INTENT_MEDIA_PAUSE = "HassMediaPause"
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
+INTENT_PLAYER_MUTE = "HassMediaPlayerMute"
+INTENT_PLAYER_UNMUTE = "HassMediaPlayerUnmute"
INTENT_SET_VOLUME = "HassSetVolume"
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
@@ -130,6 +134,8 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
),
)
intent.async_register(hass, MediaSetVolumeRelativeHandler())
+ intent.async_register(hass, MediaPlayerMuteUnmuteHandler(True))
+ intent.async_register(hass, MediaPlayerMuteUnmuteHandler(False))
intent.async_register(hass, MediaSearchAndPlayHandler())
@@ -231,6 +237,42 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
)
+class MediaPlayerMuteUnmuteHandler(intent.ServiceIntentHandler):
+ """Handle Mute/Unmute intents."""
+
+ def __init__(self, is_volume_muted: bool) -> None:
+ """Initialize the mute/unmute handler objects."""
+
+ super().__init__(
+ (INTENT_PLAYER_MUTE if is_volume_muted else INTENT_PLAYER_UNMUTE),
+ DOMAIN,
+ SERVICE_VOLUME_MUTE,
+ required_domains={DOMAIN},
+ required_features=MediaPlayerEntityFeature.VOLUME_MUTE,
+ optional_slots={
+ ATTR_MEDIA_VOLUME_MUTED: intent.IntentSlotInfo(
+ description="Whether the media player should be muted or unmuted",
+ value_schema=vol.Boolean(),
+ ),
+ },
+ description=(
+ "Mutes a media player" if is_volume_muted else "Unmutes a media player"
+ ),
+ platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
+ )
+ self.is_volume_muted = is_volume_muted
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+
+ intent_obj.slots["is_volume_muted"] = {
+ "value": self.is_volume_muted,
+ "text": str(self.is_volume_muted),
+ }
+ return await super().async_handle(intent_obj)
+
+
class MediaSearchAndPlayHandler(intent.IntentHandler):
"""Handle HassMediaSearchAndPlay intents."""
@@ -355,7 +397,6 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
# Success
response = intent_obj.create_response()
response.async_set_speech_slots({"media": first_result.as_dict()})
- response.response_type = intent.IntentResponseType.ACTION_DONE
return response
@@ -471,6 +512,5 @@ class MediaSetVolumeRelativeHandler(intent.IntentHandler):
) from err
response = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_states(match_result.states)
return response
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index ac359de1a5b..26a2624a61c 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -131,17 +131,11 @@ play_media:
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
fields:
- media_content_id:
+ media:
required: true
- example: "https://home-assistant.io/images/cast/splash.png"
selector:
- text:
-
- media_content_type:
- required: true
- example: "music"
- selector:
- text:
+ media:
+ example: '{"media_content_id": "https://home-assistant.io/images/cast/splash.png", "media_content_type": "music"}'
enqueue:
filter:
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index 617cb258af7..74cd9bc3beb 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -242,13 +242,9 @@
"name": "Play media",
"description": "Starts playing specified media.",
"fields": {
- "media_content_id": {
- "name": "Content ID",
- "description": "The ID of the content to play. Platform dependent."
- },
- "media_content_type": {
- "name": "Content type",
- "description": "The type of the content to play, such as image, music, tv show, video, episode, channel, or playlist."
+ "media": {
+ "name": "Media",
+ "description": "The media selected to play."
},
"enqueue": {
"name": "Enqueue",
@@ -270,7 +266,7 @@
},
"media_content_type": {
"name": "Content type",
- "description": "The type of the content to browse, such as image, music, tv show, video, episode, channel, or playlist."
+ "description": "The type of the content to browse, such as image, music, TV show, video, episode, channel, or playlist."
}
}
},
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index efd7c6670d2..e15a7cb47e3 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -2,30 +2,17 @@
from __future__ import annotations
-from collections.abc import Callable
-from typing import Any, Protocol
+from typing import Protocol
-import voluptuous as vol
-
-from homeassistant.components import frontend, websocket_api
-from homeassistant.components.media_player import (
- ATTR_MEDIA_CONTENT_ID,
- CONTENT_AUTH_EXPIRY_TIME,
- BrowseError,
- BrowseMedia,
- async_process_play_media_url,
-)
-from homeassistant.components.websocket_api import ActiveConnection
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
-from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
-from homeassistant.loader import bind_hass
+from homeassistant.helpers.typing import ConfigType
-from . import local_source
+from . import http, local_source
from .const import (
DOMAIN,
MEDIA_CLASS_MAP,
@@ -34,7 +21,8 @@ from .const import (
URI_SCHEME,
URI_SCHEME_REGEX,
)
-from .error import MediaSourceError, UnknownMediaSource, Unresolvable
+from .error import MediaSourceError, Unresolvable
+from .helper import async_browse_media, async_resolve_media
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
__all__ = [
@@ -80,12 +68,13 @@ def generate_media_source_id(domain: str, identifier: str) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media_source component."""
hass.data[MEDIA_SOURCE_DATA] = {}
- websocket_api.async_register_command(hass, websocket_browse_media)
- websocket_api.async_register_command(hass, websocket_resolve_media)
- frontend.async_register_built_in_panel(
- hass, "media-browser", "media_browser", "hass:play-box-multiple"
- )
- local_source.async_setup(hass)
+ http.async_setup(hass)
+
+ # Local sources support
+ await _process_media_source_platform(hass, DOMAIN, local_source)
+ hass.http.register_view(local_source.UploadMediaView)
+ websocket_api.async_register_command(hass, local_source.websocket_remove_media)
+
await async_process_integration_platforms(
hass, DOMAIN, _process_media_source_platform
)
@@ -98,142 +87,7 @@ async def _process_media_source_platform(
platform: MediaSourceProtocol,
) -> None:
"""Process a media source platform."""
- hass.data[MEDIA_SOURCE_DATA][domain] = await platform.async_get_media_source(hass)
-
-
-@callback
-def _get_media_item(
- hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None
-) -> MediaSourceItem:
- """Return media item."""
- if media_content_id:
- item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player)
- else:
- # We default to our own domain if its only one registered
- domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN
- return MediaSourceItem(hass, domain, "", target_media_player)
-
- if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]:
- raise UnknownMediaSource(
- translation_domain=DOMAIN,
- translation_key="unknown_media_source",
- translation_placeholders={"domain": item.domain},
- )
-
- return item
-
-
-@bind_hass
-async def async_browse_media(
- hass: HomeAssistant,
- media_content_id: str | None,
- *,
- content_filter: Callable[[BrowseMedia], bool] | None = None,
-) -> BrowseMediaSource:
- """Return media player browse media results."""
- if DOMAIN not in hass.data:
- raise BrowseError("Media Source not loaded")
-
- try:
- item = await _get_media_item(hass, media_content_id, None).async_browse()
- except ValueError as err:
- raise BrowseError(
- translation_domain=DOMAIN,
- translation_key="browse_media_failed",
- translation_placeholders={
- "media_content_id": str(media_content_id),
- "error": str(err),
- },
- ) from err
-
- if content_filter is None or item.children is None:
- return item
-
- old_count = len(item.children)
- item.children = [
- child for child in item.children if child.can_expand or content_filter(child)
- ]
- item.not_shown += old_count - len(item.children)
- return item
-
-
-@bind_hass
-async def async_resolve_media(
- hass: HomeAssistant,
- media_content_id: str,
- target_media_player: str | None | UndefinedType = UNDEFINED,
-) -> PlayMedia:
- """Get info to play media."""
- if DOMAIN not in hass.data:
- raise Unresolvable("Media Source not loaded")
-
- if target_media_player is UNDEFINED:
- report_usage(
- "calls media_source.async_resolve_media without passing an entity_id",
- exclude_integrations={DOMAIN},
- )
- target_media_player = None
-
- try:
- item = _get_media_item(hass, media_content_id, target_media_player)
- except ValueError as err:
- raise Unresolvable(
- translation_domain=DOMAIN,
- translation_key="resolve_media_failed",
- translation_placeholders={
- "media_content_id": str(media_content_id),
- "error": str(err),
- },
- ) from err
-
- return await item.async_resolve()
-
-
-@websocket_api.websocket_command(
- {
- vol.Required("type"): "media_source/browse_media",
- vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str,
- }
-)
-@websocket_api.async_response
-async def websocket_browse_media(
- hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
-) -> None:
- """Browse available media."""
- try:
- media = await async_browse_media(hass, msg.get("media_content_id", ""))
- connection.send_result(
- msg["id"],
- media.as_dict(),
- )
- except BrowseError as err:
- connection.send_error(msg["id"], "browse_media_failed", str(err))
-
-
-@websocket_api.websocket_command(
- {
- vol.Required("type"): "media_source/resolve_media",
- vol.Required(ATTR_MEDIA_CONTENT_ID): str,
- vol.Optional("expires", default=CONTENT_AUTH_EXPIRY_TIME): int,
- }
-)
-@websocket_api.async_response
-async def websocket_resolve_media(
- hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
-) -> None:
- """Resolve media."""
- try:
- media = await async_resolve_media(hass, msg["media_content_id"], None)
- except Unresolvable as err:
- connection.send_error(msg["id"], "resolve_media_failed", str(err))
- return
-
- connection.send_result(
- msg["id"],
- {
- "url": async_process_play_media_url(
- hass, media.url, allow_relative_url=True
- ),
- "mime_type": media.mime_type,
- },
- )
+ source = await platform.async_get_media_source(hass)
+ hass.data[MEDIA_SOURCE_DATA][domain] = source
+ if isinstance(source, local_source.LocalSource):
+ hass.http.register_view(local_source.LocalMediaView(hass, source))
diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py
new file mode 100644
index 00000000000..940b67c33c6
--- /dev/null
+++ b/homeassistant/components/media_source/helper.py
@@ -0,0 +1,103 @@
+"""Helpers for media source."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from homeassistant.components.media_player import BrowseError, BrowseMedia
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.frame import report_usage
+from homeassistant.helpers.typing import UNDEFINED, UndefinedType
+from homeassistant.loader import bind_hass
+
+from .const import DOMAIN, MEDIA_SOURCE_DATA
+from .error import UnknownMediaSource, Unresolvable
+from .models import BrowseMediaSource, MediaSourceItem, PlayMedia
+
+
+@callback
+def _get_media_item(
+ hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None
+) -> MediaSourceItem:
+ """Return media item."""
+ if media_content_id:
+ item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player)
+ else:
+ # We default to our own domain if its only one registered
+ domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN
+ return MediaSourceItem(hass, domain, "", target_media_player)
+
+ if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]:
+ raise UnknownMediaSource(
+ translation_domain=DOMAIN,
+ translation_key="unknown_media_source",
+ translation_placeholders={"domain": item.domain},
+ )
+
+ return item
+
+
+@bind_hass
+async def async_browse_media(
+ hass: HomeAssistant,
+ media_content_id: str | None,
+ *,
+ content_filter: Callable[[BrowseMedia], bool] | None = None,
+) -> BrowseMediaSource:
+ """Return media player browse media results."""
+ if DOMAIN not in hass.data:
+ raise BrowseError("Media Source not loaded")
+
+ try:
+ item = await _get_media_item(hass, media_content_id, None).async_browse()
+ except ValueError as err:
+ raise BrowseError(
+ translation_domain=DOMAIN,
+ translation_key="browse_media_failed",
+ translation_placeholders={
+ "media_content_id": str(media_content_id),
+ "error": str(err),
+ },
+ ) from err
+
+ if content_filter is None or item.children is None:
+ return item
+
+ old_count = len(item.children)
+ item.children = [
+ child for child in item.children if child.can_expand or content_filter(child)
+ ]
+ item.not_shown += old_count - len(item.children)
+ return item
+
+
+@bind_hass
+async def async_resolve_media(
+ hass: HomeAssistant,
+ media_content_id: str,
+ target_media_player: str | None | UndefinedType = UNDEFINED,
+) -> PlayMedia:
+ """Get info to play media."""
+ if DOMAIN not in hass.data:
+ raise Unresolvable("Media Source not loaded")
+
+ if target_media_player is UNDEFINED:
+ report_usage(
+ "calls media_source.async_resolve_media without passing an entity_id",
+ exclude_integrations={DOMAIN},
+ )
+ target_media_player = None
+
+ try:
+ item = _get_media_item(hass, media_content_id, target_media_player)
+ except ValueError as err:
+ raise Unresolvable(
+ translation_domain=DOMAIN,
+ translation_key="resolve_media_failed",
+ translation_placeholders={
+ "media_content_id": str(media_content_id),
+ "error": str(err),
+ },
+ ) from err
+
+ return await item.async_resolve()
diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py
new file mode 100644
index 00000000000..3c6388db944
--- /dev/null
+++ b/homeassistant/components/media_source/http.py
@@ -0,0 +1,79 @@
+"""HTTP views and WebSocket commands for media sources."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import frontend, websocket_api
+from homeassistant.components.media_player import (
+ ATTR_MEDIA_CONTENT_ID,
+ CONTENT_AUTH_EXPIRY_TIME,
+ BrowseError,
+ async_process_play_media_url,
+)
+from homeassistant.components.websocket_api import ActiveConnection
+from homeassistant.core import HomeAssistant
+
+from .error import Unresolvable
+from .helper import async_browse_media, async_resolve_media
+
+
+def async_setup(hass: HomeAssistant) -> None:
+ """Set up the HTTP views and WebSocket commands for media sources."""
+ websocket_api.async_register_command(hass, websocket_browse_media)
+ websocket_api.async_register_command(hass, websocket_resolve_media)
+ frontend.async_register_built_in_panel(
+ hass, "media-browser", "media_browser", "mdi:play-box-multiple"
+ )
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "media_source/browse_media",
+ vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str,
+ }
+)
+@websocket_api.async_response
+async def websocket_browse_media(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Browse available media."""
+ try:
+ media = await async_browse_media(hass, msg.get("media_content_id", ""))
+ connection.send_result(
+ msg["id"],
+ media.as_dict(),
+ )
+ except BrowseError as err:
+ connection.send_error(msg["id"], "browse_media_failed", str(err))
+
+
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "media_source/resolve_media",
+ vol.Required(ATTR_MEDIA_CONTENT_ID): str,
+ vol.Optional("expires", default=CONTENT_AUTH_EXPIRY_TIME): int,
+ }
+)
+@websocket_api.async_response
+async def websocket_resolve_media(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Resolve media."""
+ try:
+ media = await async_resolve_media(hass, msg["media_content_id"], None)
+ except Unresolvable as err:
+ connection.send_error(msg["id"], "resolve_media_failed", str(err))
+ return
+
+ connection.send_result(
+ msg["id"],
+ {
+ "url": async_process_play_media_url(
+ hass, media.url, allow_relative_url=True
+ ),
+ "mime_type": media.mime_type,
+ },
+ )
diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py
index fa30dc9baf3..bbfa288d595 100644
--- a/homeassistant/components/media_source/local_source.py
+++ b/homeassistant/components/media_source/local_source.py
@@ -2,11 +2,12 @@
from __future__ import annotations
+import io
import logging
import mimetypes
from pathlib import Path
import shutil
-from typing import Any, cast
+from typing import Any, Protocol, cast
from aiohttp import web
from aiohttp.web_request import FileField
@@ -16,6 +17,7 @@ from homeassistant.components import http, websocket_api
from homeassistant.components.http import require_admin
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA
@@ -26,30 +28,49 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 10
LOGGER = logging.getLogger(__name__)
-@callback
-def async_setup(hass: HomeAssistant) -> None:
+class PathNotSupportedError(HomeAssistantError):
+ """Error to indicate a path is not supported."""
+
+
+class InvalidFileNameError(HomeAssistantError):
+ """Error to indicate an invalid file name."""
+
+
+class UploadedFile(Protocol):
+ """Protocol describing properties of an uploaded file."""
+
+ filename: str
+ file: io.IOBase
+ content_type: str
+
+
+async def async_get_media_source(hass: HomeAssistant) -> LocalSource:
"""Set up local media source."""
- source = LocalSource(hass)
- hass.data[MEDIA_SOURCE_DATA][DOMAIN] = source
- hass.http.register_view(LocalMediaView(hass, source))
- hass.http.register_view(UploadMediaView(hass, source))
- websocket_api.async_register_command(hass, websocket_remove_media)
+ return LocalSource(hass, DOMAIN, "My media", hass.config.media_dirs, "/media")
class LocalSource(MediaSource):
"""Provide local directories as media sources."""
- name: str = "My media"
-
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ domain: str,
+ name: str,
+ media_dirs: dict[str, str],
+ url_prefix: str,
+ ) -> None:
"""Initialize local source."""
- super().__init__(DOMAIN)
+ super().__init__(domain)
self.hass = hass
+ self.name = name
+ self.media_dirs = media_dirs
+ self.url_prefix = url_prefix
@callback
def async_full_path(self, source_dir_id: str, location: str) -> Path:
"""Return full path."""
- base_path = self.hass.config.media_dirs[source_dir_id]
+ base_path = self.media_dirs[source_dir_id]
full_path = Path(base_path, location)
full_path.relative_to(base_path)
return full_path
@@ -57,11 +78,11 @@ class LocalSource(MediaSource):
@callback
def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]:
"""Parse identifier."""
- if item.domain != DOMAIN:
+ if item.domain != self.domain:
raise Unresolvable("Unknown domain.")
source_dir_id, _, location = item.identifier.partition("/")
- if source_dir_id not in self.hass.config.media_dirs:
+ if source_dir_id not in self.media_dirs:
raise Unresolvable("Unknown source directory.")
try:
@@ -74,13 +95,70 @@ class LocalSource(MediaSource):
return source_dir_id, location
+ async def async_delete_media(self, item: MediaSourceItem) -> None:
+ """Delete media."""
+ source_dir_id, location = self.async_parse_identifier(item)
+ item_path = self.async_full_path(source_dir_id, location)
+
+ def _do_delete() -> None:
+ if not item_path.exists():
+ raise FileNotFoundError("Path does not exist")
+
+ if not item_path.is_file():
+ raise PathNotSupportedError("Path is not a file")
+
+ item_path.unlink()
+
+ await self.hass.async_add_executor_job(_do_delete)
+
+ async def async_upload_media(
+ self, target_folder: MediaSourceItem, uploaded_file: UploadedFile
+ ) -> str:
+ """Upload media.
+
+ Return value is the media source ID of the uploaded file.
+ """
+ source_dir_id, location = self.async_parse_identifier(target_folder)
+
+ if not uploaded_file.content_type.startswith(("image/", "video/", "audio/")):
+ LOGGER.error("Content type not allowed")
+ raise vol.Invalid("Only images and video are allowed")
+
+ try:
+ raise_if_invalid_filename(uploaded_file.filename)
+ except ValueError as err:
+ raise InvalidFileNameError from err
+
+ target_dir = self.async_full_path(source_dir_id, location)
+
+ def _do_move() -> None:
+ """Move file to target."""
+ try:
+ target_path = target_dir / uploaded_file.filename
+
+ target_path.relative_to(target_dir)
+ raise_if_invalid_path(str(target_path))
+
+ target_dir.mkdir(parents=True, exist_ok=True)
+ except ValueError as err:
+ raise PathNotSupportedError("Invalid path") from err
+
+ with target_path.open("wb") as target_fp:
+ shutil.copyfileobj(uploaded_file.file, target_fp)
+
+ await self.hass.async_add_executor_job(
+ _do_move,
+ )
+
+ return f"{target_folder.media_source_id}/{uploaded_file.filename}"
+
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
source_dir_id, location = self.async_parse_identifier(item)
path = self.async_full_path(source_dir_id, location)
mime_type, _ = mimetypes.guess_type(str(path))
assert isinstance(mime_type, str)
- return PlayMedia(f"/media/{item.identifier}", mime_type, path=path)
+ return PlayMedia(f"{self.url_prefix}/{item.identifier}", mime_type, path=path)
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Return media."""
@@ -103,8 +181,8 @@ class LocalSource(MediaSource):
"""Browse media."""
# If only one media dir is configured, use that as the local media root
- if source_dir_id is None and len(self.hass.config.media_dirs) == 1:
- source_dir_id = list(self.hass.config.media_dirs)[0]
+ if source_dir_id is None and len(self.media_dirs) == 1:
+ source_dir_id = list(self.media_dirs)[0]
# Multiple folder, root is requested
if source_dir_id is None:
@@ -112,7 +190,7 @@ class LocalSource(MediaSource):
raise BrowseError("Folder not found.")
base = BrowseMediaSource(
- domain=DOMAIN,
+ domain=self.domain,
identifier="",
media_class=MediaClass.DIRECTORY,
media_content_type=None,
@@ -124,12 +202,12 @@ class LocalSource(MediaSource):
base.children = [
self._browse_media(source_dir_id, "")
- for source_dir_id in self.hass.config.media_dirs
+ for source_dir_id in self.media_dirs
]
return base
- full_path = Path(self.hass.config.media_dirs[source_dir_id], location)
+ full_path = Path(self.media_dirs[source_dir_id], location)
if not full_path.exists():
if location == "":
@@ -170,8 +248,8 @@ class LocalSource(MediaSource):
)
media = BrowseMediaSource(
- domain=DOMAIN,
- identifier=f"{source_dir_id}/{path.relative_to(self.hass.config.media_dirs[source_dir_id])}",
+ domain=self.domain,
+ identifier=f"{source_dir_id}/{path.relative_to(self.media_dirs[source_dir_id])}",
media_class=media_class,
media_content_type=mime_type or "",
title=title,
@@ -202,13 +280,14 @@ class LocalMediaView(http.HomeAssistantView):
Returns media files in config/media.
"""
- url = "/media/{source_dir_id}/{location:.*}"
name = "media"
def __init__(self, hass: HomeAssistant, source: LocalSource) -> None:
"""Initialize the media view."""
self.hass = hass
self.source = source
+ self.name = source.url_prefix.strip("/").replace("/", ":")
+ self.url = f"{source.url_prefix}/{{source_dir_id}}/{{location:.*}}"
async def _validate_media_path(self, source_dir_id: str, location: str) -> Path:
"""Validate media path and return it if valid."""
@@ -217,7 +296,7 @@ class LocalMediaView(http.HomeAssistantView):
except ValueError as err:
raise web.HTTPBadRequest from err
- if source_dir_id not in self.hass.config.media_dirs:
+ if source_dir_id not in self.source.media_dirs:
raise web.HTTPNotFound
media_path = self.source.async_full_path(source_dir_id, location)
@@ -258,21 +337,18 @@ class UploadMediaView(http.HomeAssistantView):
url = "/api/media_source/local_source/upload"
name = "api:media_source:local_source:upload"
-
- def __init__(self, hass: HomeAssistant, source: LocalSource) -> None:
- """Initialize the media view."""
- self.hass = hass
- self.source = source
- self.schema = vol.Schema(
- {
- "media_content_id": str,
- "file": FileField,
- }
- )
+ schema = vol.Schema(
+ {
+ "media_content_id": str,
+ "file": FileField,
+ }
+ )
@require_admin
async def post(self, request: web.Request) -> web.Response:
"""Handle upload."""
+ hass = request.app[http.KEY_HASS]
+
# Increase max payload
request._client_max_size = MAX_UPLOAD_SIZE # noqa: SLF001
@@ -283,55 +359,35 @@ class UploadMediaView(http.HomeAssistantView):
raise web.HTTPBadRequest from err
try:
- item = MediaSourceItem.from_uri(self.hass, data["media_content_id"], None)
+ target_folder = MediaSourceItem.from_uri(
+ hass, data["media_content_id"], None
+ )
except ValueError as err:
LOGGER.error("Received invalid upload data: %s", err)
raise web.HTTPBadRequest from err
+ if target_folder.domain != DOMAIN:
+ raise web.HTTPBadRequest
+
+ source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][target_folder.domain])
try:
- source_dir_id, location = self.source.async_parse_identifier(item)
- except Unresolvable as err:
- LOGGER.error("Invalid local source ID")
- raise web.HTTPBadRequest from err
-
- uploaded_file: FileField = data["file"]
-
- if not uploaded_file.content_type.startswith(("image/", "video/", "audio/")):
- LOGGER.error("Content type not allowed")
- raise vol.Invalid("Only images and video are allowed")
-
- try:
- raise_if_invalid_filename(uploaded_file.filename)
- except ValueError as err:
- LOGGER.error("Invalid filename")
- raise web.HTTPBadRequest from err
-
- try:
- await self.hass.async_add_executor_job(
- self._move_file,
- self.source.async_full_path(source_dir_id, location),
- uploaded_file,
+ uploaded_media_source_id = await source.async_upload_media(
+ target_folder, data["file"]
)
- except ValueError as err:
- LOGGER.error("Moving upload failed: %s", err)
+ except Unresolvable as err:
+ LOGGER.error("Invalid local source ID: %s", data["media_content_id"])
raise web.HTTPBadRequest from err
+ except InvalidFileNameError as err:
+ LOGGER.error("Invalid filename uploaded: %s", data["file"].filename)
+ raise web.HTTPBadRequest from err
+ except PathNotSupportedError as err:
+ LOGGER.error("Invalid path for upload: %s", data["media_content_id"])
+ raise web.HTTPBadRequest from err
+ except OSError as err:
+ LOGGER.error("Error uploading file: %s", err)
+ raise web.HTTPInternalServerError from err
- return self.json(
- {"media_content_id": f"{data['media_content_id']}/{uploaded_file.filename}"}
- )
-
- def _move_file(self, target_dir: Path, uploaded_file: FileField) -> None:
- """Move file to target."""
- if not target_dir.is_dir():
- raise ValueError("Target is not an existing directory")
-
- target_path = target_dir / uploaded_file.filename
-
- target_path.relative_to(target_dir)
- raise_if_invalid_path(str(target_path))
-
- with target_path.open("wb") as target_fp:
- shutil.copyfileobj(uploaded_file.file, target_fp)
+ return self.json({"media_content_id": uploaded_media_source_id})
@websocket_api.websocket_command(
@@ -352,32 +408,23 @@ async def websocket_remove_media(
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
- source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][DOMAIN])
-
- try:
- source_dir_id, location = source.async_parse_identifier(item)
- except Unresolvable as err:
- connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
+ if item.domain != DOMAIN:
+ connection.send_error(
+ msg["id"], websocket_api.ERR_INVALID_FORMAT, "Invalid media source domain"
+ )
return
- item_path = source.async_full_path(source_dir_id, location)
-
- def _do_delete() -> tuple[str, str] | None:
- if not item_path.exists():
- return websocket_api.ERR_NOT_FOUND, "Path does not exist"
-
- if not item_path.is_file():
- return websocket_api.ERR_NOT_SUPPORTED, "Path is not a file"
-
- item_path.unlink()
- return None
+ source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain])
try:
- error = await hass.async_add_executor_job(_do_delete)
+ await source.async_delete_media(item)
+ except Unresolvable as err:
+ connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
+ except FileNotFoundError as err:
+ connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
+ except PathNotSupportedError as err:
+ connection.send_error(msg["id"], websocket_api.ERR_NOT_SUPPORTED, str(err))
except OSError as err:
- error = (websocket_api.ERR_UNKNOWN_ERROR, str(err))
-
- if error:
- connection.send_error(msg["id"], *error)
+ connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
else:
connection.send_result(msg["id"])
diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py
index 2cf5d231741..ac633e8753d 100644
--- a/homeassistant/components/media_source/models.py
+++ b/homeassistant/components/media_source/models.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.translation import async_get_cached_translations
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
@@ -62,12 +63,15 @@ class MediaSourceItem:
async def async_browse(self) -> BrowseMediaSource:
"""Browse this item."""
if self.domain is None:
+ title = async_get_cached_translations(
+ self.hass, self.hass.config.language, "common", "media_source"
+ ).get("component.media_source.common.sources_default", "Media Sources")
base = BrowseMediaSource(
domain=None,
identifier=None,
media_class=MediaClass.APP,
media_content_type=MediaType.APPS,
- title="Media Sources",
+ title=title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.APP,
diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json
index 40204fc32db..12f69ad4390 100644
--- a/homeassistant/components/media_source/strings.json
+++ b/homeassistant/components/media_source/strings.json
@@ -9,5 +9,8 @@
"unknown_media_source": {
"message": "Unknown media source: {domain}"
}
+ },
+ "common": {
+ "sources_default": "Media sources"
}
}
diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json
index a9440ad8300..6032cd3e17d 100644
--- a/homeassistant/components/melcloud/manifest.json
+++ b/homeassistant/components/melcloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/melcloud",
"iot_class": "cloud_polling",
"loggers": ["pymelcloud"],
- "requirements": ["python-melcloud==0.1.0"]
+ "requirements": ["python-melcloud==0.1.2"]
}
diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py
index 8b6243d9daf..b2c43cb1361 100644
--- a/homeassistant/components/met/coordinator.py
+++ b/homeassistant/components/met/coordinator.py
@@ -83,7 +83,9 @@ class MetWeatherData:
self.current_weather_data = self._weather_data.get_current_weather()
time_zone = dt_util.get_default_time_zone()
self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0)
- self.hourly_forecast = self._weather_data.get_forecast(time_zone, True)
+ self.hourly_forecast = self._weather_data.get_forecast(
+ time_zone, True, range_stop=49
+ )
return self
diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py
index cde2812b059..285e508a661 100644
--- a/homeassistant/components/meteo_france/const.py
+++ b/homeassistant/components/meteo_france/const.py
@@ -49,7 +49,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Bancs de Brouillard",
"Brouillard dense",
],
- ATTR_CONDITION_HAIL: ["Risque de grêle", "Risque de grèle"],
+ ATTR_CONDITION_HAIL: ["Risque de grêle", "Averses de grêle"],
ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"],
ATTR_CONDITION_LIGHTNING_RAINY: [
"Pluie orageuses",
diff --git a/homeassistant/components/meteo_lt/__init__.py b/homeassistant/components/meteo_lt/__init__.py
new file mode 100644
index 00000000000..8e508e76203
--- /dev/null
+++ b/homeassistant/components/meteo_lt/__init__.py
@@ -0,0 +1,27 @@
+"""The Meteo.lt integration."""
+
+from __future__ import annotations
+
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_PLACE_CODE, PLATFORMS
+from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
+ """Set up Meteo.lt from a config entry."""
+
+ coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/meteo_lt/config_flow.py b/homeassistant/components/meteo_lt/config_flow.py
new file mode 100644
index 00000000000..b9478e8b37e
--- /dev/null
+++ b/homeassistant/components/meteo_lt/config_flow.py
@@ -0,0 +1,78 @@
+"""Config flow for Meteo.lt integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import aiohttp
+from meteo_lt import MeteoLtAPI, Place
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+
+from .const import CONF_PLACE_CODE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Meteo.lt."""
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self._api = MeteoLtAPI()
+ self._places: list[Place] = []
+ self._selected_place: Place | None = None
+
+ 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:
+ place_code = user_input[CONF_PLACE_CODE]
+ self._selected_place = next(
+ (place for place in self._places if place.code == place_code),
+ None,
+ )
+ if self._selected_place:
+ await self.async_set_unique_id(self._selected_place.code)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=self._selected_place.name,
+ data={
+ CONF_PLACE_CODE: self._selected_place.code,
+ },
+ )
+ errors["base"] = "invalid_location"
+
+ if not self._places:
+ try:
+ await self._api.fetch_places()
+ self._places = self._api.places
+ except (aiohttp.ClientError, TimeoutError) as err:
+ _LOGGER.error("Error fetching places: %s", err)
+ return self.async_abort(reason="cannot_connect")
+
+ if not self._places:
+ return self.async_abort(reason="no_places_found")
+
+ places_options = {
+ place.code: f"{place.name} ({place.administrative_division})"
+ for place in self._places
+ }
+
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_PLACE_CODE): vol.In(places_options),
+ }
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=data_schema,
+ errors=errors,
+ )
diff --git a/homeassistant/components/meteo_lt/const.py b/homeassistant/components/meteo_lt/const.py
new file mode 100644
index 00000000000..96aee80b15e
--- /dev/null
+++ b/homeassistant/components/meteo_lt/const.py
@@ -0,0 +1,17 @@
+"""Constants for the Meteo.lt integration."""
+
+from datetime import timedelta
+
+from homeassistant.const import Platform
+
+DOMAIN = "meteo_lt"
+PLATFORMS = [Platform.WEATHER]
+
+MANUFACTURER = "Lithuanian Hydrometeorological Service"
+MODEL = "Weather Station"
+
+DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30)
+
+CONF_PLACE_CODE = "place_code"
+
+ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)"
diff --git a/homeassistant/components/meteo_lt/coordinator.py b/homeassistant/components/meteo_lt/coordinator.py
new file mode 100644
index 00000000000..12044f6fe78
--- /dev/null
+++ b/homeassistant/components/meteo_lt/coordinator.py
@@ -0,0 +1,61 @@
+"""DataUpdateCoordinator for Meteo.lt integration."""
+
+from __future__ import annotations
+
+import logging
+
+import aiohttp
+from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator]
+
+
+class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]):
+ """Class to manage fetching Meteo.lt data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ place_code: str,
+ config_entry: MeteoLtConfigEntry,
+ ) -> None:
+ """Initialize the coordinator."""
+ self.client = MeteoLtAPI()
+ self.place_code = place_code
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=DEFAULT_UPDATE_INTERVAL,
+ config_entry=config_entry,
+ )
+
+ async def _async_update_data(self) -> MeteoLtForecast:
+ """Fetch data from Meteo.lt API."""
+ try:
+ forecast = await self.client.get_forecast(self.place_code)
+ except aiohttp.ClientResponseError as err:
+ raise UpdateFailed(
+ f"API returned error status {err.status}: {err.message}"
+ ) from err
+ except aiohttp.ClientConnectionError as err:
+ raise UpdateFailed(f"Cannot connect to API: {err}") from err
+ except (aiohttp.ClientError, TimeoutError) as err:
+ raise UpdateFailed(f"Error communicating with API: {err}") from err
+
+ # Check if forecast data is available
+ if not forecast.forecast_timestamps:
+ raise UpdateFailed(
+ f"No forecast data available for {self.place_code} - API returned empty timestamps"
+ )
+
+ return forecast
diff --git a/homeassistant/components/meteo_lt/manifest.json b/homeassistant/components/meteo_lt/manifest.json
new file mode 100644
index 00000000000..9bd97f4574c
--- /dev/null
+++ b/homeassistant/components/meteo_lt/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "meteo_lt",
+ "name": "Meteo.lt",
+ "codeowners": ["@xE1H"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/meteo_lt",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["meteo-lt-pkg==0.2.4"]
+}
diff --git a/homeassistant/components/meteo_lt/quality_scale.yaml b/homeassistant/components/meteo_lt/quality_scale.yaml
new file mode 100644
index 00000000000..52b6505412f
--- /dev/null
+++ b/homeassistant/components/meteo_lt/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration does not provide custom service actions to document.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: Weather entities do not require event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Integration does not register custom service actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: Public weather service that does not require authentication.
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: Integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: Weather stations cannot be automatically discovered.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: Single weather entity per config entry, no dynamic device addition.
+ entity-category:
+ status: exempt
+ comment: Weather entities are primary entities and do not require categories.
+ entity-device-class:
+ status: exempt
+ comment: Weather entities have implicit device class from the platform.
+ entity-disabled-by-default:
+ status: exempt
+ comment: Primary weather entity should be enabled by default.
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: Weather entities use standard condition-based icons.
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: No dynamic device management required.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/meteo_lt/strings.json b/homeassistant/components/meteo_lt/strings.json
new file mode 100644
index 00000000000..9289961f01c
--- /dev/null
+++ b/homeassistant/components/meteo_lt/strings.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Select station",
+ "data": {
+ "place_code": "Station"
+ },
+ "data_description": {
+ "place_code": "Weather station to get data from"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect to Meteo.lt API",
+ "invalid_location": "Selected station is invalid",
+ "unknown": "Unexpected error occurred"
+ },
+ "abort": {
+ "already_configured": "Station is already configured",
+ "cannot_connect": "Failed to connect to Meteo.lt API",
+ "no_places_found": "No stations found from the API"
+ }
+ }
+}
diff --git a/homeassistant/components/meteo_lt/weather.py b/homeassistant/components/meteo_lt/weather.py
new file mode 100644
index 00000000000..902a899dbc3
--- /dev/null
+++ b/homeassistant/components/meteo_lt/weather.py
@@ -0,0 +1,190 @@
+"""Weather platform for Meteo.lt integration."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import datetime
+from typing import Any
+
+from homeassistant.components.weather import (
+ Forecast,
+ WeatherEntity,
+ WeatherEntityFeature,
+)
+from homeassistant.const import (
+ UnitOfPrecipitationDepth,
+ UnitOfPressure,
+ UnitOfSpeed,
+ UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL
+from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MeteoLtConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the weather platform."""
+ coordinator = entry.runtime_data
+
+ async_add_entities([MeteoLtWeatherEntity(coordinator)])
+
+
+class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherEntity):
+ """Weather entity for Meteo.lt."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+ _attr_attribution = ATTRIBUTION
+ _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
+ _attr_native_pressure_unit = UnitOfPressure.HPA
+ _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
+ _attr_supported_features = (
+ WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
+ )
+
+ def __init__(self, coordinator: MeteoLtUpdateCoordinator) -> None:
+ """Initialize the weather entity."""
+ super().__init__(coordinator)
+
+ self._place_code = coordinator.place_code
+ self._attr_unique_id = str(self._place_code)
+
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, self._place_code)},
+ manufacturer=MANUFACTURER,
+ model=MODEL,
+ )
+
+ @property
+ def native_temperature(self) -> float | None:
+ """Return the temperature."""
+ return self.coordinator.data.current_conditions.temperature
+
+ @property
+ def native_apparent_temperature(self) -> float | None:
+ """Return the apparent temperature."""
+ return self.coordinator.data.current_conditions.apparent_temperature
+
+ @property
+ def humidity(self) -> int | None:
+ """Return the humidity."""
+ return self.coordinator.data.current_conditions.humidity
+
+ @property
+ def native_pressure(self) -> float | None:
+ """Return the pressure."""
+ return self.coordinator.data.current_conditions.pressure
+
+ @property
+ def native_wind_speed(self) -> float | None:
+ """Return the wind speed."""
+ return self.coordinator.data.current_conditions.wind_speed
+
+ @property
+ def wind_bearing(self) -> int | None:
+ """Return the wind bearing."""
+ return self.coordinator.data.current_conditions.wind_bearing
+
+ @property
+ def native_wind_gust_speed(self) -> float | None:
+ """Return the wind gust speed."""
+ return self.coordinator.data.current_conditions.wind_gust_speed
+
+ @property
+ def cloud_coverage(self) -> int | None:
+ """Return the cloud coverage."""
+ return self.coordinator.data.current_conditions.cloud_coverage
+
+ @property
+ def condition(self) -> str | None:
+ """Return the current condition."""
+ return self.coordinator.data.current_conditions.condition
+
+ def _convert_forecast_data(
+ self, forecast_data: Any, include_templow: bool = False
+ ) -> Forecast:
+ """Convert forecast timestamp data to Forecast object."""
+ return Forecast(
+ datetime=forecast_data.datetime,
+ native_temperature=forecast_data.temperature,
+ native_templow=forecast_data.temperature_low if include_templow else None,
+ native_apparent_temperature=forecast_data.apparent_temperature,
+ condition=forecast_data.condition,
+ native_precipitation=forecast_data.precipitation,
+ precipitation_probability=None, # Not provided by API
+ native_wind_speed=forecast_data.wind_speed,
+ wind_bearing=forecast_data.wind_bearing,
+ cloud_coverage=forecast_data.cloud_coverage,
+ )
+
+ async def async_forecast_daily(self) -> list[Forecast] | None:
+ """Return the daily forecast."""
+ # Using hourly data to create daily summaries, since daily data is not provided directly
+ if not self.coordinator.data:
+ return None
+
+ forecasts_by_date = defaultdict(list)
+ for timestamp in self.coordinator.data.forecast_timestamps:
+ date = datetime.fromisoformat(timestamp.datetime).date()
+ forecasts_by_date[date].append(timestamp)
+
+ daily_forecasts = []
+ for date in sorted(forecasts_by_date.keys())[:5]:
+ day_forecasts = forecasts_by_date[date]
+ if not day_forecasts:
+ continue
+
+ temps = [
+ ts.temperature for ts in day_forecasts if ts.temperature is not None
+ ]
+ max_temp = max(temps) if temps else None
+ min_temp = min(temps) if temps else None
+
+ midday_forecast = min(
+ day_forecasts,
+ key=lambda ts: abs(datetime.fromisoformat(ts.datetime).hour - 12),
+ )
+
+ daily_forecast = Forecast(
+ datetime=day_forecasts[0].datetime,
+ native_temperature=max_temp,
+ native_templow=min_temp,
+ native_apparent_temperature=midday_forecast.apparent_temperature,
+ condition=midday_forecast.condition,
+ # Calculate precipitation: sum if any values, else None
+ native_precipitation=(
+ sum(
+ ts.precipitation
+ for ts in day_forecasts
+ if ts.precipitation is not None
+ )
+ if any(ts.precipitation is not None for ts in day_forecasts)
+ else None
+ ),
+ precipitation_probability=None,
+ native_wind_speed=midday_forecast.wind_speed,
+ wind_bearing=midday_forecast.wind_bearing,
+ cloud_coverage=midday_forecast.cloud_coverage,
+ )
+ daily_forecasts.append(daily_forecast)
+
+ return daily_forecasts
+
+ async def async_forecast_hourly(self) -> list[Forecast] | None:
+ """Return the hourly forecast."""
+ if not self.coordinator.data:
+ return None
+ return [
+ self._convert_forecast_data(forecast_data)
+ for forecast_data in self.coordinator.data.forecast_timestamps[:24]
+ ]
diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py
index fc3972eac2a..479edaa60ba 100644
--- a/homeassistant/components/metoffice/sensor.py
+++ b/homeassistant/components/metoffice/sensor.py
@@ -21,6 +21,7 @@ from homeassistant.const import (
PERCENTAGE,
UV_INDEX,
UnitOfLength,
+ UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
@@ -160,6 +161,16 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
icon=None,
entity_registry_enabled_default=False,
),
+ MetOfficeSensorEntityDescription(
+ key="pressure",
+ native_attr_name="mslp",
+ name="Pressure",
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPressure.PA,
+ suggested_unit_of_measurement=UnitOfPressure.HPA,
+ entity_registry_enabled_default=False,
+ ),
)
diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py
index 24d020823c8..07637c817b1 100644
--- a/homeassistant/components/miele/climate.py
+++ b/homeassistant/components/miele/climate.py
@@ -8,7 +8,7 @@ import logging
from typing import Any, Final, cast
import aiohttp
-from pymiele import MieleDevice
+from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import (
ClimateEntity,
@@ -31,6 +31,15 @@ PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
+def _get_temperature_value(
+ temperatures: list[MieleTemperature], index: int
+) -> float | None:
+ """Return the temperature value for the given index."""
+ if len(temperatures) > index:
+ return cast(int, temperatures[index].temperature) / 100.0
+ return None
+
+
@dataclass(frozen=True, kw_only=True)
class MieleClimateDescription(ClimateEntityDescription):
"""Class describing Miele climate entities."""
@@ -62,11 +71,10 @@ CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = (
description=MieleClimateDescription(
key="thermostat",
value_fn=(
- lambda value: cast(int, value.state_temperatures[0].temperature) / 100.0
+ lambda value: _get_temperature_value(value.state_temperatures, 0)
),
target_fn=(
- lambda value: cast(int, value.state_target_temperature[0].temperature)
- / 100.0
+ lambda value: _get_temperature_value(value.state_target_temperature, 0)
),
zone=1,
),
@@ -84,11 +92,10 @@ CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = (
description=MieleClimateDescription(
key="thermostat2",
value_fn=(
- lambda value: cast(int, value.state_temperatures[1].temperature) / 100.0
+ lambda value: _get_temperature_value(value.state_temperatures, 1)
),
target_fn=(
- lambda value: cast(int, value.state_target_temperature[1].temperature)
- / 100.0
+ lambda value: _get_temperature_value(value.state_target_temperature, 1)
),
translation_key="zone_2",
zone=2,
@@ -107,11 +114,10 @@ CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = (
description=MieleClimateDescription(
key="thermostat3",
value_fn=(
- lambda value: cast(int, value.state_temperatures[2].temperature) / 100.0
+ lambda value: _get_temperature_value(value.state_temperatures, 2)
),
target_fn=(
- lambda value: cast(int, value.state_target_temperature[2].temperature)
- / 100.0
+ lambda value: _get_temperature_value(value.state_target_temperature, 2)
),
translation_key="zone_3",
zone=3,
@@ -219,6 +225,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
@property
def max_temp(self) -> float:
"""Return the maximum target temperature."""
+ if len(self.action.target_temperature) < self.entity_description.zone:
+ return super().max_temp
return cast(
float,
self.action.target_temperature[self.entity_description.zone - 1].max,
@@ -227,6 +235,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
@property
def min_temp(self) -> float:
"""Return the minimum target temperature."""
+ if len(self.action.target_temperature) < self.entity_description.zone:
+ return super().min_temp
return cast(
float,
self.action.target_temperature[self.entity_description.zone - 1].min,
diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py
index fb5e04fbff0..5e0303b44cf 100644
--- a/homeassistant/components/miele/const.py
+++ b/homeassistant/components/miele/const.py
@@ -167,178 +167,263 @@ PROCESS_ACTIONS = {
"stop_supercooling": MieleActions.STOP_SUPERCOOL,
}
-STATE_PROGRAM_PHASE_WASHING_MACHINE = {
- 0: "not_running", # Returned by the API when the machine is switched off entirely.
- 256: "not_running",
- 257: "pre_wash",
- 258: "soak",
- 259: "pre_wash",
- 260: "main_wash",
- 261: "rinse",
- 262: "rinse_hold",
- 263: "cleaning",
- 264: "cooling_down",
- 265: "drain",
- 266: "spin",
- 267: "anti_crease",
- 268: "finished",
- 269: "venting",
- 270: "starch_stop",
- 271: "freshen_up_and_moisten",
- 272: "steam_smoothing",
- 279: "hygiene",
- 280: "drying",
- 285: "disinfecting",
- 295: "steam_smoothing",
- 65535: "not_running", # Seems to be default for some devices.
-}
-STATE_PROGRAM_PHASE_TUMBLE_DRYER = {
- 0: "not_running",
- 512: "not_running",
- 513: "program_running",
- 514: "drying",
- 515: "machine_iron",
- 516: "hand_iron_2",
- 517: "normal",
- 518: "normal_plus",
- 519: "cooling_down",
- 520: "hand_iron_1",
- 521: "anti_crease",
- 522: "finished",
- 523: "extra_dry",
- 524: "hand_iron",
- 526: "moisten",
- 527: "thermo_spin",
- 528: "timed_drying",
- 529: "warm_air",
- 530: "steam_smoothing",
- 531: "comfort_cooling",
- 532: "rinse_out_lint",
- 533: "rinses",
- 535: "not_running",
- 534: "smoothing",
- 536: "not_running",
- 537: "not_running",
- 538: "slightly_dry",
- 539: "safety_cooling",
- 65535: "not_running",
-}
+class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
+ """Program phase codes for washing machines."""
-STATE_PROGRAM_PHASE_DISHWASHER = {
- 1792: "not_running",
- 1793: "reactivating",
- 1794: "pre_dishwash",
- 1795: "main_dishwash",
- 1796: "rinse",
- 1797: "interim_rinse",
- 1798: "final_rinse",
- 1799: "drying",
- 1800: "finished",
- 1801: "pre_dishwash",
- 65535: "not_running",
-}
+ not_running = 0, 256, 65535
+ pre_wash = 257, 259
+ soak = 258
+ main_wash = 260
+ rinse = 261
+ rinse_hold = 262
+ cleaning = 263
+ cooling_down = 264
+ drain = 265
+ spin = 266
+ anti_crease = 267
+ finished = 268
+ venting = 269
+ starch_stop = 270
+ freshen_up_and_moisten = 271
+ steam_smoothing = 272, 295
+ hygiene = 279
+ drying = 280
+ disinfecting = 285
-STATE_PROGRAM_PHASE_OVEN = {
- 0: "not_running",
- 3073: "heating_up",
- 3074: "process_running",
- 3078: "process_finished",
- 3084: "energy_save",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE_WARMING_DRAWER = {
- 0: "not_running",
- 3073: "heating_up",
- 3075: "door_open",
- 3094: "keeping_warm",
- 3088: "cooling_down",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE_MICROWAVE = {
- 0: "not_running",
- 3329: "heating",
- 3330: "process_running",
- 3334: "process_finished",
- 3340: "energy_save",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE_COFFEE_SYSTEM = {
- # Coffee system
- 3073: "heating_up",
- 4352: "not_running",
- 4353: "espresso",
- 4355: "milk_foam",
- 4361: "dispensing",
- 4369: "pre_brewing",
- 4377: "grinding",
- 4401: "2nd_grinding",
- 4354: "hot_milk",
- 4393: "2nd_pre_brewing",
- 4385: "2nd_espresso",
- 4404: "dispensing",
- 4405: "rinse",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER = {
- 0: "not_running",
- 5889: "vacuum_cleaning",
- 5890: "returning",
- 5891: "vacuum_cleaning_paused",
- 5892: "going_to_target_area",
- 5893: "wheel_lifted", # F1
- 5894: "dirty_sensors", # F2
- 5895: "dust_box_missing", # F3
- 5896: "blocked_drive_wheels", # F4
- 5897: "blocked_brushes", # F5
- 5898: "motor_overload", # F6
- 5899: "internal_fault", # F7
- 5900: "blocked_front_wheel", # F8
- 5903: "docked",
- 5904: "docked",
- 5910: "remote_controlled",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE_STEAM_OVEN = {
- 0: "not_running",
- 3863: "steam_reduction",
- 7938: "process_running",
- 7939: "waiting_for_start",
- 7940: "heating_up_phase",
- 7942: "process_finished",
- 65535: "not_running",
-}
-STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = {
- MieleAppliance.WASHING_MACHINE: STATE_PROGRAM_PHASE_WASHING_MACHINE,
- MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE,
- MieleAppliance.WASHING_MACHINE_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE,
- MieleAppliance.TUMBLE_DRYER: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
- MieleAppliance.DRYER_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
- MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER,
- MieleAppliance.WASHER_DRYER: STATE_PROGRAM_PHASE_WASHING_MACHINE
- | STATE_PROGRAM_PHASE_TUMBLE_DRYER,
- MieleAppliance.DISHWASHER: STATE_PROGRAM_PHASE_DISHWASHER,
- MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER,
- MieleAppliance.DISHWASHER_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER,
- MieleAppliance.OVEN: STATE_PROGRAM_PHASE_OVEN,
- MieleAppliance.OVEN_MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE,
- MieleAppliance.STEAM_OVEN: STATE_PROGRAM_PHASE_STEAM_OVEN,
- MieleAppliance.STEAM_OVEN_COMBI: STATE_PROGRAM_PHASE_OVEN
- | STATE_PROGRAM_PHASE_STEAM_OVEN,
- MieleAppliance.STEAM_OVEN_MICRO: STATE_PROGRAM_PHASE_MICROWAVE
- | STATE_PROGRAM_PHASE_STEAM_OVEN,
- MieleAppliance.STEAM_OVEN_MK2: STATE_PROGRAM_PHASE_OVEN
- | STATE_PROGRAM_PHASE_STEAM_OVEN,
- MieleAppliance.DIALOG_OVEN: STATE_PROGRAM_PHASE_OVEN,
- MieleAppliance.MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE,
- MieleAppliance.COFFEE_SYSTEM: STATE_PROGRAM_PHASE_COFFEE_SYSTEM,
- MieleAppliance.ROBOT_VACUUM_CLEANER: STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER,
- MieleAppliance.DISH_WARMER: STATE_PROGRAM_PHASE_WARMING_DRAWER,
+class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
+ """Program phase codes for tumble dryers."""
+
+ not_running = 0, 512, 535, 536, 537, 65535
+ program_running = 513
+ drying = 514
+ machine_iron = 515
+ hand_iron_2 = 516
+ normal = 517
+ normal_plus = 518
+ cooling_down = 519
+ hand_iron_1 = 520
+ anti_crease = 521
+ finished = 522
+ extra_dry = 523
+ hand_iron = 524
+ moisten = 526
+ thermo_spin = 527
+ timed_drying = 528
+ warm_air = 529
+ steam_smoothing = 530
+ comfort_cooling = 531
+ rinse_out_lint = 532
+ rinses = 533
+ smoothing = 534
+ slightly_dry = 538
+ safety_cooling = 539
+
+
+class ProgramPhaseWasherDryer(MieleEnum, missing_to_none=True):
+ """Program phase codes for washer/dryer machines."""
+
+ not_running = 0, 256, 512, 535, 536, 537, 65535
+ pre_wash = 257, 259
+ soak = 258
+ main_wash = 260
+ rinse = 261
+ rinse_hold = 262
+ cleaning = 263
+ cooling_down = 264, 519
+ drain = 265
+ spin = 266
+ anti_crease = 267, 521
+ finished = 268, 522
+ venting = 269
+ starch_stop = 270
+ freshen_up_and_moisten = 271
+ steam_smoothing = 272, 295, 530
+ hygiene = 279
+ drying = 280, 514
+ disinfecting = 285
+
+ program_running = 513
+ machine_iron = 515
+ hand_iron_2 = 516
+ normal = 517
+ normal_plus = 518
+ hand_iron_1 = 520
+ extra_dry = 523
+ hand_iron = 524
+ moisten = 526
+ thermo_spin = 527
+ timed_drying = 528
+ warm_air = 529
+ comfort_cooling = 531
+ rinse_out_lint = 532
+ rinses = 533
+ smoothing = 534
+ slightly_dry = 538
+ safety_cooling = 539
+
+
+class ProgramPhaseDishwasher(MieleEnum, missing_to_none=True):
+ """Program phase codes for dishwashers."""
+
+ not_running = 0, 1792, 65535
+ reactivating = 1793
+ pre_dishwash = 1794, 1801
+ main_dishwash = 1795
+ rinse = 1796
+ interim_rinse = 1797
+ final_rinse = 1798
+ drying = 1799
+ finished = 1800
+
+
+class ProgramPhaseOven(MieleEnum, missing_to_none=True):
+ """Program phase codes for ovens."""
+
+ not_running = 0, 65535
+ heating_up = 3073
+ process_running = 3074
+ process_finished = 3078
+ energy_save = 3084
+ pre_heating = 3099
+
+
+class ProgramPhaseWarmingDrawer(MieleEnum, missing_to_none=True):
+ """Program phase codes for warming drawers."""
+
+ not_running = 0, 65535
+ heating_up = 3073
+ door_open = 3075
+ keeping_warm = 3094
+ cooling_down = 3088
+
+
+class ProgramPhaseMicrowave(MieleEnum, missing_to_none=True):
+ """Program phase for microwave units."""
+
+ not_running = 0, 65535
+ heating = 3329
+ process_running = 3330
+ process_finished = 3334
+ energy_save = 3340
+
+
+class ProgramPhaseCoffeeSystem(MieleEnum, missing_to_none=True):
+ """Program phase codes for coffee systems."""
+
+ not_running = 0, 4352, 65535
+ heating_up = 3073
+ espresso = 4353
+ hot_milk = 4354
+ milk_foam = 4355
+ dispensing = 4361, 4404
+ pre_brewing = 4369
+ grinding = 4377
+ second_espresso = 4385
+ second_pre_brewing = 4393
+ second_grinding = 4401
+ rinse = 4405
+
+
+class ProgramPhaseRobotVacuumCleaner(MieleEnum, missing_to_none=True):
+ """Program phase codes for robot vacuum cleaner."""
+
+ not_running = 0, 65535
+ vacuum_cleaning = 5889
+ returning = 5890
+ vacuum_cleaning_paused = 5891
+ going_to_target_area = 5892
+ wheel_lifted = 5893 # F1
+ dirty_sensors = 5894 # F2
+ dust_box_missing = 5895 # F3
+ blocked_drive_wheels = 5896 # F4
+ blocked_brushes = 5897 # F5
+ motor_overload = 5898 # F6
+ internal_fault = 5899 # F7
+ blocked_front_wheel = 5900 # F8
+ docked = 5903, 5904
+ remote_controlled = 5910
+
+
+class ProgramPhaseMicrowaveOvenCombo(MieleEnum, missing_to_none=True):
+ """Program phase codes for microwave oven combo."""
+
+ not_running = 0, 65535
+ steam_reduction = 3863
+ process_running = 7938
+ waiting_for_start = 7939
+ heating_up_phase = 7940
+ process_finished = 7942
+
+
+class ProgramPhaseSteamOven(MieleEnum, missing_to_none=True):
+ """Program phase codes for steam ovens."""
+
+ not_running = 0, 65535
+ steam_reduction = 3863
+ process_running = 7938
+ waiting_for_start = 7939
+ heating_up_phase = 7940
+ process_finished = 7942
+
+
+class ProgramPhaseSteamOvenCombi(MieleEnum, missing_to_none=True):
+ """Program phase codes for steam oven combi."""
+
+ not_running = 0, 65535
+ heating_up = 3073
+ process_running = 3074, 7938
+ process_finished = 3078, 7942
+ energy_save = 3084
+ pre_heating = 3099
+
+ steam_reduction = 3863
+ waiting_for_start = 7939
+ heating_up_phase = 7940
+
+
+class ProgramPhaseSteamOvenMicro(MieleEnum, missing_to_none=True):
+ """Program phase codes for steam oven micro."""
+
+ not_running = 0, 65535
+
+ heating = 3329
+ process_running = 3330, 7938, 7942
+ process_finished = 3334
+ energy_save = 3340
+
+ steam_reduction = 3863
+ waiting_for_start = 7939
+ heating_up_phase = 7940
+
+
+PROGRAM_PHASE: dict[int, type[MieleEnum]] = {
+ MieleAppliance.WASHING_MACHINE: ProgramPhaseWashingMachine,
+ MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: ProgramPhaseWashingMachine,
+ MieleAppliance.WASHING_MACHINE_PROFESSIONAL: ProgramPhaseWashingMachine,
+ MieleAppliance.TUMBLE_DRYER: ProgramPhaseTumbleDryer,
+ MieleAppliance.DRYER_PROFESSIONAL: ProgramPhaseTumbleDryer,
+ MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: ProgramPhaseTumbleDryer,
+ MieleAppliance.WASHER_DRYER: ProgramPhaseWasherDryer,
+ MieleAppliance.DISHWASHER: ProgramPhaseDishwasher,
+ MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: ProgramPhaseDishwasher,
+ MieleAppliance.DISHWASHER_PROFESSIONAL: ProgramPhaseDishwasher,
+ MieleAppliance.OVEN: ProgramPhaseOven,
+ MieleAppliance.OVEN_MICROWAVE: ProgramPhaseMicrowaveOvenCombo,
+ MieleAppliance.STEAM_OVEN: ProgramPhaseSteamOven,
+ MieleAppliance.STEAM_OVEN_COMBI: ProgramPhaseSteamOvenCombi,
+ MieleAppliance.STEAM_OVEN_MK2: ProgramPhaseSteamOvenCombi,
+ MieleAppliance.STEAM_OVEN_MICRO: ProgramPhaseSteamOvenMicro,
+ MieleAppliance.DIALOG_OVEN: ProgramPhaseOven,
+ MieleAppliance.MICROWAVE: ProgramPhaseMicrowave,
+ MieleAppliance.COFFEE_SYSTEM: ProgramPhaseCoffeeSystem,
+ MieleAppliance.ROBOT_VACUUM_CLEANER: ProgramPhaseRobotVacuumCleaner,
+ MieleAppliance.DISH_WARMER: ProgramPhaseWarmingDrawer,
}
-class StateProgramType(MieleEnum):
+class StateProgramType(MieleEnum, missing_to_none=True):
"""Defines program types."""
normal_operation_mode = 0
@@ -346,10 +431,9 @@ class StateProgramType(MieleEnum):
automatic_program = 2
cleaning_care_program = 3
maintenance_program = 4
- missing2none = -9999
-class StateDryingStep(MieleEnum):
+class StateDryingStep(MieleEnum, missing_to_none=True):
"""Defines drying steps."""
extra_dry = 0
@@ -360,7 +444,6 @@ class StateDryingStep(MieleEnum):
hand_iron_2 = 5
machine_iron = 6
smoothing = 7
- missing2none = -9999
WASHING_MACHINE_PROGRAM_ID: dict[int, str] = {
@@ -1314,7 +1397,7 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = {
}
-class PlatePowerStep(MieleEnum):
+class PlatePowerStep(MieleEnum, missing_to_none=True):
"""Plate power settings."""
plate_step_0 = 0
@@ -1339,4 +1422,3 @@ class PlatePowerStep(MieleEnum):
plate_step_18 = 18
plate_step_boost = 117, 118, 218
plate_step_boost_2 = 217
- missing2none = -9999
diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py
index 98f5c9f8b1c..b3eb1185bd1 100644
--- a/homeassistant/components/miele/coordinator.py
+++ b/homeassistant/components/miele/coordinator.py
@@ -2,12 +2,13 @@
from __future__ import annotations
-import asyncio.timeouts
+import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
+from aiohttp import ClientResponseError
from pymiele import MieleAction, MieleAPI, MieleDevice
from homeassistant.config_entries import ConfigEntry
@@ -66,7 +67,22 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
self.devices = devices
actions = {}
for device_id in devices:
- actions_json = await self.api.get_actions(device_id)
+ try:
+ actions_json = await self.api.get_actions(device_id)
+ except ClientResponseError as err:
+ _LOGGER.debug(
+ "Error fetching actions for device %s: Status: %s, Message: %s",
+ device_id,
+ err.status,
+ err.message,
+ )
+ actions_json = {}
+ except TimeoutError:
+ _LOGGER.debug(
+ "Timeout fetching actions for device %s",
+ device_id,
+ )
+ actions_json = {}
actions[device_id] = MieleAction(actions_json)
return MieleCoordinatorData(devices=devices, actions=actions)
diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json
index a5dbeb4ec2d..da9816c3af8 100644
--- a/homeassistant/components/miele/icons.json
+++ b/homeassistant/components/miele/icons.json
@@ -45,7 +45,7 @@
"default": "mdi:tray-full"
},
"elapsed_time": {
- "default": "mdi:timelapse"
+ "default": "mdi:timer-outline"
},
"start_time": {
"default": "mdi:clock-start"
diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json
index 63ace343dc8..2ed00c564d1 100644
--- a/homeassistant/components/miele/manifest.json
+++ b/homeassistant/components/miele/manifest.json
@@ -7,8 +7,8 @@
"documentation": "https://www.home-assistant.io/integrations/miele",
"iot_class": "cloud_push",
"loggers": ["pymiele"],
- "quality_scale": "bronze",
- "requirements": ["pymiele==0.5.4"],
+ "quality_scale": "platinum",
+ "requirements": ["pymiele==0.5.5"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}
diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml
index 94ce68278ef..d66f46dc770 100644
--- a/homeassistant/components/miele/quality_scale.yaml
+++ b/homeassistant/components/miele/quality_scale.yaml
@@ -1,19 +1,13 @@
rules:
# Bronze
- action-setup:
- status: exempt
- comment: |
- No custom actions are defined.
+ action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
- docs-actions:
- status: exempt
- comment: |
- No custom actions are defined.
+ docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
@@ -32,9 +26,7 @@ rules:
Handled by a setting in manifest.json as there is no account information in API
# Silver
- action-exceptions:
- status: done
- comment: No custom actions are defined
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -50,7 +42,7 @@ rules:
comment: Handled by DataUpdateCoordinator
parallel-updates: done
reauthentication-flow: done
- test-coverage: todo
+ test-coverage: done
# Gold
devices: done
@@ -61,11 +53,11 @@ rules:
Discovery is just used to initiate setup of the integration. No data from devices is collected.
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: todo
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py
index 988f25accdc..60e7fba5969 100644
--- a/homeassistant/components/miele/sensor.py
+++ b/homeassistant/components/miele/sensor.py
@@ -35,8 +35,8 @@ from .const import (
COFFEE_SYSTEM_PROFILE,
DISABLED_TEMP_ENTITIES,
DOMAIN,
+ PROGRAM_PHASE,
STATE_PROGRAM_ID,
- STATE_PROGRAM_PHASE,
STATE_STATUS_TAGS,
MieleAppliance,
PlatePowerStep,
@@ -270,6 +270,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
@@ -307,6 +308,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfVolume.LITERS,
+ suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
@@ -364,6 +366,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
key="state_remaining_time",
translation_key="remaining_time",
value_fn=lambda value: _convert_duration(value.state_remaining_time),
+ end_value_fn=lambda last_value: 0,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -417,6 +420,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
key="state_start_time",
translation_key="start_time",
value_fn=lambda value: _convert_duration(value.state_start_time),
+ end_value_fn=lambda last_value: None,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -614,6 +618,10 @@ async def async_setup_entry(
"state_program_phase": MielePhaseSensor,
"state_plate_step": MielePlateSensor,
"state_elapsed_time": MieleTimeSensor,
+ "state_remaining_time": MieleTimeSensor,
+ "state_start_time": MieleTimeSensor,
+ "current_energy_consumption": MieleConsumptionSensor,
+ "current_water_consumption": MieleConsumptionSensor,
}.get(definition.description.key, MieleSensor)
def _is_entity_registered(unique_id: str) -> bool:
@@ -769,7 +777,7 @@ class MieleRestorableSensor(MieleSensor, RestoreSensor):
"""When entity is added to hass."""
await super().async_added_to_hass()
- # recover last value from cache
+ # recover last value from cache when adding entity
last_value = await self.async_get_last_state()
if last_value and last_value.state != STATE_UNKNOWN:
self._last_value = last_value.state
@@ -779,6 +787,16 @@ class MieleRestorableSensor(MieleSensor, RestoreSensor):
"""Return the state of the sensor."""
return self._last_value
+ def _update_last_value(self) -> None:
+ """Update the last value of the sensor."""
+ self._last_value = self.entity_description.value_fn(self.device)
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_last_value()
+ super()._handle_coordinator_update()
+
class MielePlateSensor(MieleSensor):
"""Representation of a Sensor."""
@@ -833,29 +851,36 @@ class MieleStatusSensor(MieleSensor):
return True
+# Some phases have names that are not valid python identifiers, so we need to translate
+# them in order to avoid breaking changes
+PROGRAM_PHASE_TRANSLATION = {
+ "second_espresso": "2nd_espresso",
+ "second_grinding": "2nd_grinding",
+ "second_pre_brewing": "2nd_pre_brewing",
+}
+
+
class MielePhaseSensor(MieleSensor):
"""Representation of the program phase sensor."""
@property
def native_value(self) -> StateType:
- """Return the state of the sensor."""
- ret_val = STATE_PROGRAM_PHASE.get(self.device.device_type, {}).get(
+ """Return the state of the phase sensor."""
+ program_phase = PROGRAM_PHASE[self.device.device_type](
self.device.state_program_phase
+ ).name
+
+ return (
+ PROGRAM_PHASE_TRANSLATION.get(program_phase, program_phase)
+ if program_phase is not None
+ else None
)
- if ret_val is None:
- _LOGGER.debug(
- "Unknown program phase: %s on device type: %s",
- self.device.state_program_phase,
- self.device.device_type,
- )
- return ret_val
@property
def options(self) -> list[str]:
"""Return the options list for the actual device type."""
- return sorted(
- set(STATE_PROGRAM_PHASE.get(self.device.device_type, {}).values())
- )
+ phases = PROGRAM_PHASE[self.device.device_type].keys()
+ return sorted([PROGRAM_PHASE_TRANSLATION.get(phase, phase) for phase in phases])
class MieleProgramIdSensor(MieleSensor):
@@ -886,9 +911,8 @@ class MieleProgramIdSensor(MieleSensor):
class MieleTimeSensor(MieleRestorableSensor):
"""Representation of time sensors keeping state from cache."""
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _update_last_value(self) -> None:
+ """Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status)
@@ -912,4 +936,57 @@ class MieleTimeSensor(MieleRestorableSensor):
else:
self._last_value = current_value
- super()._handle_coordinator_update()
+
+class MieleConsumptionSensor(MieleRestorableSensor):
+ """Representation of consumption sensors keeping state from cache."""
+
+ _is_reporting: bool = False
+
+ def _update_last_value(self) -> None:
+ """Update the last value of the sensor."""
+ current_value = self.entity_description.value_fn(self.device)
+ current_status = StateStatus(self.device.state_status)
+ last_value = (
+ float(cast(str, self._last_value))
+ if self._last_value is not None and self._last_value != STATE_UNKNOWN
+ else 0
+ )
+
+ # force unknown when appliance is not able to report consumption
+ if current_status in (
+ StateStatus.ON,
+ StateStatus.OFF,
+ StateStatus.PROGRAMMED,
+ StateStatus.WAITING_TO_START,
+ StateStatus.IDLE,
+ StateStatus.SERVICE,
+ ):
+ self._is_reporting = False
+ self._last_value = None
+
+ # appliance might report the last value for consumption of previous cycle and it will report 0
+ # only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
+ # we already saw a valid value in this cycle from cache
+ elif (
+ current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
+ and not self._is_reporting
+ and last_value > 0
+ ):
+ self._last_value = current_value
+ self._is_reporting = True
+
+ elif (
+ current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
+ and not self._is_reporting
+ and current_value is not None
+ and cast(int, current_value) > 0
+ ):
+ self._last_value = 0
+
+ # keep value when program ends
+ elif current_status == StateStatus.PROGRAM_ENDED:
+ pass
+
+ else:
+ self._last_value = current_value
+ self._is_reporting = True
diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py
index 517b489173d..da8ee861f46 100644
--- a/homeassistant/components/miele/services.py
+++ b/homeassistant/components/miele/services.py
@@ -58,11 +58,12 @@ _LOGGER = logging.getLogger(__name__)
async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry:
"""Extract config entry from the service call."""
- hass = service_call.hass
- target_entry_ids = await async_extract_config_entry_ids(hass, service_call)
+ target_entry_ids = await async_extract_config_entry_ids(service_call)
target_entries: list[MieleConfigEntry] = [
loaded_entry
- for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN)
+ for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
+ DOMAIN
+ )
if loaded_entry.entry_id in target_entry_ids
]
if not target_entries:
diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json
index 4f0fa48e724..47fdc7136d8 100644
--- a/homeassistant/components/miele/strings.json
+++ b/homeassistant/components/miele/strings.json
@@ -291,6 +291,7 @@
"not_running": "Not running",
"pre_brewing": "Pre-brewing",
"pre_dishwash": "Pre-cleaning",
+ "pre_heating": "Pre-heating",
"pre_wash": "Pre-wash",
"process_finished": "Process finished",
"process_running": "Process running",
diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py
index 999ceac5cce..8ca2713f59f 100644
--- a/homeassistant/components/miele/vacuum.py
+++ b/homeassistant/components/miele/vacuum.py
@@ -64,7 +64,7 @@ PROGRAM_TO_SPEED: dict[int, str] = {
}
-class MieleVacuumStateCode(MieleEnum):
+class MieleVacuumStateCode(MieleEnum, missing_to_none=True):
"""Define vacuum state codes."""
idle = 0
@@ -82,7 +82,6 @@ class MieleVacuumStateCode(MieleEnum):
blocked_front_wheel = 5900
docked = 5903, 5904
remote_controlled = 5910
- missing2none = -9999
SUPPORTED_FEATURES = (
diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json
index c5cc94ead30..40051aeb1e6 100644
--- a/homeassistant/components/mill/manifest.json
+++ b/homeassistant/components/mill/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
- "requirements": ["millheater==0.12.5", "mill-local==0.3.0"]
+ "requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
}
diff --git a/homeassistant/components/min_max/__init__.py b/homeassistant/components/min_max/__init__.py
index a027a029ec2..9090de908fb 100644
--- a/homeassistant/components/min_max/__init__.py
+++ b/homeassistant/components/min_max/__init__.py
@@ -11,16 +11,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Min/Max from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
-
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/min_max/config_flow.py b/homeassistant/components/min_max/config_flow.py
index 36133f7394d..2b7b38beb46 100644
--- a/homeassistant/components/min_max/config_flow.py
+++ b/homeassistant/components/min_max/config_flow.py
@@ -71,6 +71,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py
index 9039c3e9e24..ea4491ebc79 100644
--- a/homeassistant/components/min_max/sensor.py
+++ b/homeassistant/components/min_max/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_TYPE,
@@ -278,13 +279,18 @@ class MinMaxSensor(SensorEntity):
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the sensor."""
+ attributes: dict[str, list[str] | str | None] = {
+ ATTR_ENTITY_ID: self._entity_ids
+ }
+
if self._sensor_type == "min":
- return {ATTR_MIN_ENTITY_ID: self.min_entity_id}
- if self._sensor_type == "max":
- return {ATTR_MAX_ENTITY_ID: self.max_entity_id}
- if self._sensor_type == "last":
- return {ATTR_LAST_ENTITY_ID: self.last_entity_id}
- return None
+ attributes[ATTR_MIN_ENTITY_ID] = self.min_entity_id
+ elif self._sensor_type == "max":
+ attributes[ATTR_MAX_ENTITY_ID] = self.max_entity_id
+ elif self._sensor_type == "last":
+ attributes[ATTR_LAST_ENTITY_ID] = self.last_entity_id
+
+ return attributes
@callback
def _async_min_max_sensor_state_listener(
diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json
index 5fdb43f704a..6e4651ab0db 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.5.0"]
+ "requirements": ["PyNaCl==1.6.0"]
}
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index ab387030af8..1847c4fb738 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -148,7 +148,7 @@ from .const import (
DEFAULT_HVAC_ON_VALUE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TEMP_UNIT,
- MODBUS_DOMAIN as DOMAIN,
+ DOMAIN,
RTUOVERTCP,
SERIAL,
TCP,
@@ -267,8 +267,8 @@ CLIMATE_SCHEMA = vol.All(
{
vol.Required(CONF_TARGET_TEMP): hvac_fixedsize_reglist_validator,
vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean,
- vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float),
- vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float),
+ vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(int),
+ vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(int),
vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float),
vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string,
vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int,
@@ -479,7 +479,8 @@ MODBUS_SCHEMA = vol.Schema(
),
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
vol.Optional(CONF_FANS): vol.All(cv.ensure_list, [FAN_SCHEMA]),
- }
+ },
+ extra=vol.ALLOW_EXTRA,
)
SERIAL_SCHEMA = MODBUS_SCHEMA.extend(
diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py
index a7e2cd51a65..c230cfc5379 100644
--- a/homeassistant/components/modbus/binary_sensor.py
+++ b/homeassistant/components/modbus/binary_sensor.py
@@ -29,7 +29,7 @@ from .const import (
CONF_SLAVE_COUNT,
CONF_VIRTUAL_COUNT,
)
-from .entity import BasePlatform
+from .entity import ModbusBaseEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -59,7 +59,7 @@ async def async_setup_platform(
async_add_entities(sensors)
-class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity):
+class ModbusBinarySensor(ModbusBaseEntity, RestoreEntity, BinarySensorEntity):
"""Modbus binary sensor."""
def __init__(
@@ -106,7 +106,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity):
# do not allow multiple active calls to the same platform
result = await self._hub.async_pb_call(
- self._slave, self._address, self._count, self._input_type
+ self._device_address, self._address, self._count, self._input_type
)
if result is None:
self._attr_available = False
@@ -157,5 +157,8 @@ class SlaveSensor(
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
result = self.coordinator.data
- self._attr_is_on = bool(result[self._result_inx] & 1) if result else None
+ if not result or self._result_inx >= len(result):
+ self._attr_is_on = None
+ else:
+ self._attr_is_on = bool(result[self._result_inx] & 1)
super()._handle_coordinator_update()
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index f8e7dca245a..a99a8839ba3 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -101,7 +101,7 @@ from .const import (
CONF_WRITE_REGISTERS,
DataType,
)
-from .entity import BaseStructPlatform
+from .entity import ModbusStructEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -131,7 +131,7 @@ async def async_setup_platform(
async_add_entities(ModbusThermostat(hass, hub, config) for config in climates)
-class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
+class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity):
"""Representation of a Modbus Thermostat."""
_attr_supported_features = (
@@ -315,7 +315,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
# register, or self._hvac_on_value otherwise.
if self._hvac_onoff_write_registers:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._hvac_onoff_register,
[
self._hvac_off_value
@@ -326,7 +326,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
)
else:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._hvac_onoff_register,
self._hvac_off_value
if hvac_mode == HVACMode.OFF
@@ -337,7 +337,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
if self._hvac_onoff_coil is not None:
# Turn HVAC Off by writing 0 to the On/Off coil, or 1 otherwise.
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._hvac_onoff_coil,
0 if hvac_mode == HVACMode.OFF else 1,
CALL_TYPE_WRITE_COIL,
@@ -349,21 +349,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
if mode == hvac_mode:
if self._hvac_mode_write_registers:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._hvac_mode_register,
[value],
CALL_TYPE_WRITE_REGISTERS,
)
else:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._hvac_mode_register,
value,
CALL_TYPE_WRITE_REGISTER,
)
break
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
@@ -372,20 +372,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
value = self._fan_mode_mapping_to_modbus[fan_mode]
if isinstance(self._fan_mode_register, list):
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._fan_mode_register[0],
[value],
CALL_TYPE_WRITE_REGISTERS,
)
else:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._fan_mode_register,
value,
CALL_TYPE_WRITE_REGISTER,
)
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing mode."""
@@ -395,20 +395,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
if swing_mode == smode:
if isinstance(self._swing_mode_register, list):
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._swing_mode_register[0],
[value],
CALL_TYPE_WRITE_REGISTERS,
)
else:
await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._swing_mode_register,
value,
CALL_TYPE_WRITE_REGISTER,
)
break
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -437,7 +437,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
):
if self._target_temperature_write_registers:
result = await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._target_temperature_register[
HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode]
],
@@ -446,7 +446,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
)
else:
result = await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._target_temperature_register[
HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode]
],
@@ -455,7 +455,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
)
else:
result = await self._hub.async_pb_call(
- self._slave,
+ self._device_address,
self._target_temperature_register[
HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode]
],
@@ -463,7 +463,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
CALL_TYPE_WRITE_REGISTERS,
)
self._attr_available = result is not None
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def _async_update(self) -> None:
"""Update Target & Current Temperature."""
@@ -566,7 +566,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
) -> float | None:
"""Read register using the Modbus hub slave."""
result = await self._hub.async_pb_call(
- self._slave, register, self._count, register_type
+ self._device_address, register, self._count, register_type
)
if result is None:
self._attr_available = False
@@ -587,7 +587,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
return float(self._value)
async def _async_read_coil(self, address: int) -> int | None:
- result = await self._hub.async_pb_call(self._slave, address, 1, CALL_TYPE_COIL)
+ result = await self._hub.async_pb_call(
+ self._device_address, address, 1, CALL_TYPE_COIL
+ )
if result is not None and result.bits is not None:
self._attr_available = True
return int(result.bits[0])
diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py
index dafc604e781..9eab4299b18 100644
--- a/homeassistant/components/modbus/const.py
+++ b/homeassistant/components/modbus/const.py
@@ -159,6 +159,7 @@ DEFAULT_TEMP_UNIT = "C"
DEFAULT_HVAC_ON_VALUE = 1
DEFAULT_HVAC_OFF_VALUE = 0
MODBUS_DOMAIN = "modbus"
+DOMAIN = "modbus"
ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update
diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py
index 23a09431072..21d04d2ffc4 100644
--- a/homeassistant/components/modbus/cover.py
+++ b/homeassistant/components/modbus/cover.py
@@ -23,7 +23,7 @@ from .const import (
CONF_STATUS_REGISTER,
CONF_STATUS_REGISTER_TYPE,
)
-from .entity import BasePlatform
+from .entity import ModbusBaseEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -42,7 +42,7 @@ async def async_setup_platform(
async_add_entities(ModbusCover(hass, hub, config) for config in covers)
-class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
+class ModbusCover(ModbusBaseEntity, CoverEntity, RestoreEntity):
"""Representation of a Modbus cover."""
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
@@ -108,23 +108,29 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open cover."""
result = await self._hub.async_pb_call(
- self._slave, self._write_address, self._state_open, self._write_type
+ self._device_address,
+ self._write_address,
+ self._state_open,
+ self._write_type,
)
self._attr_available = result is not None
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
result = await self._hub.async_pb_call(
- self._slave, self._write_address, self._state_closed, self._write_type
+ self._device_address,
+ self._write_address,
+ self._state_closed,
+ self._write_type,
)
self._attr_available = result is not None
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def _async_update(self) -> None:
"""Update the state of the cover."""
result = await self._hub.async_pb_call(
- self._slave, self._address, 1, self._input_type
+ self._device_address, self._address, 1, self._input_type
)
if result is None:
self._attr_available = False
diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py
index 689d882a2f3..4208c098902 100644
--- a/homeassistant/components/modbus/entity.py
+++ b/homeassistant/components/modbus/entity.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
+import copy
from datetime import datetime, timedelta
import struct
from typing import Any, cast
@@ -61,14 +62,13 @@ from .const import (
CONF_VIRTUAL_COUNT,
CONF_WRITE_TYPE,
CONF_ZERO_SUPPRESS,
- SIGNAL_START_ENTITY,
SIGNAL_STOP_ENTITY,
DataType,
)
from .modbus import ModbusHub
-class BasePlatform(Entity):
+class ModbusBaseEntity(Entity):
"""Base for readonly platforms."""
_value: str | None = None
@@ -83,43 +83,36 @@ class BasePlatform(Entity):
self._hub = hub
if (conf_slave := entry.get(CONF_SLAVE)) is not None:
- self._slave = conf_slave
+ self._device_address = conf_slave
else:
- self._slave = entry.get(CONF_DEVICE_ADDRESS, 1)
+ self._device_address = entry.get(CONF_DEVICE_ADDRESS, 1)
self._address = int(entry[CONF_ADDRESS])
self._input_type = entry[CONF_INPUT_TYPE]
self._scan_interval = int(entry[CONF_SCAN_INTERVAL])
- self._cancel_timer: Callable[[], None] | None = None
self._cancel_call: Callable[[], None] | None = None
self._attr_unique_id = entry.get(CONF_UNIQUE_ID)
self._attr_name = entry[CONF_NAME]
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
- def get_optional_numeric_config(config_name: str) -> int | float | None:
- if (val := entry.get(config_name)) is None:
- return None
- assert isinstance(val, (float, int)), (
- f"Expected float or int but {config_name} was {type(val)}"
- )
- return val
-
- self._min_value = get_optional_numeric_config(CONF_MIN_VALUE)
- self._max_value = get_optional_numeric_config(CONF_MAX_VALUE)
+ self._min_value = entry.get(CONF_MIN_VALUE)
+ self._max_value = entry.get(CONF_MAX_VALUE)
self._nan_value = entry.get(CONF_NAN_VALUE)
- self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
+ self._zero_suppress = entry.get(CONF_ZERO_SUPPRESS)
@abstractmethod
async def _async_update(self) -> None:
"""Virtual function to be overwritten."""
- async def async_update(self) -> None:
+ async def async_update(self, now: datetime | None = None) -> None:
"""Update the entity state."""
- if self._cancel_call:
- self._cancel_call()
- await self.async_local_update()
+ await self.async_local_update(cancel_pending_update=True)
- async def async_local_update(self, now: datetime | None = None) -> None:
+ async def async_local_update(
+ self, now: datetime | None = None, cancel_pending_update: bool = False
+ ) -> None:
"""Update the entity state."""
+ if cancel_pending_update and self._cancel_call:
+ self._cancel_call()
await self._async_update()
self.async_write_ha_state()
if self._scan_interval > 0:
@@ -129,63 +122,23 @@ class BasePlatform(Entity):
self.async_local_update,
)
- async def _async_update_write_state(self) -> None:
- """Update the entity state and write it to the state machine."""
- if self._cancel_call:
- self._cancel_call()
- self._cancel_call = None
- await self.async_local_update()
-
- async def _async_update_if_not_in_progress(
- self, now: datetime | None = None
- ) -> None:
- """Update the entity state if not already in progress."""
- await self._async_update_write_state()
+ async def async_will_remove_from_hass(self) -> None:
+ """Remove entity from hass."""
+ self.async_disable()
@callback
- def async_run(self) -> None:
- """Remote start entity."""
- self._async_cancel_update_polling()
- self._async_schedule_future_update(0.1)
- self._cancel_call = async_call_later(
- self.hass, timedelta(seconds=0.1), self.async_local_update
- )
- self._attr_available = True
- self.async_write_ha_state()
-
- @callback
- def _async_schedule_future_update(self, delay: float) -> None:
- """Schedule an update in the future."""
- self._async_cancel_future_pending_update()
- self._cancel_call = async_call_later(
- self.hass, delay, self._async_update_if_not_in_progress
- )
-
- @callback
- def _async_cancel_future_pending_update(self) -> None:
- """Cancel a future pending update."""
- if self._cancel_call:
- self._cancel_call()
- self._cancel_call = None
-
- def _async_cancel_update_polling(self) -> None:
- """Cancel the polling."""
- if self._cancel_timer:
- self._cancel_timer()
- self._cancel_timer = None
-
- @callback
- def async_hold(self) -> None:
+ def async_disable(self) -> None:
"""Remote stop entity."""
- self._async_cancel_future_pending_update()
- self._async_cancel_update_polling()
+ _LOGGER.info(f"hold entity {self._attr_name}")
+ if self._cancel_call:
+ self._cancel_call()
+ self._cancel_call = None
self._attr_available = False
- self.async_write_ha_state()
async def async_await_connection(self, _now: Any) -> None:
"""Wait for first connect."""
await self._hub.event_connected.wait()
- self.async_run()
+ await self.async_local_update(cancel_pending_update=True)
async def async_base_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@@ -197,14 +150,11 @@ class BasePlatform(Entity):
)
)
self.async_on_remove(
- async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold)
- )
- self.async_on_remove(
- async_dispatcher_connect(self.hass, SIGNAL_START_ENTITY, self.async_run)
+ async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_disable)
)
-class BaseStructPlatform(BasePlatform, RestoreEntity):
+class ModbusStructEntity(ModbusBaseEntity, RestoreEntity):
"""Base class representing a sensor/climate."""
def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None:
@@ -258,7 +208,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
def __process_raw_value(self, entry: float | str | bytes) -> str | None:
"""Process value from sensor with NaN handling, scaling, offset, min/max etc."""
- if self._nan_value and entry in (self._nan_value, -self._nan_value):
+ if self._nan_value is not None and entry in (self._nan_value, -self._nan_value):
return None
if isinstance(entry, bytes):
return entry.decode()
@@ -280,7 +230,9 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
"""Convert registers to proper result."""
if self._swap:
- registers = self._swap_registers(registers, self._slave_count)
+ registers = self._swap_registers(
+ copy.deepcopy(registers), self._slave_count
+ )
byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers])
if self._data_type == DataType.STRING:
return byte_string.decode()
@@ -309,7 +261,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
return self.__process_raw_value(val[0])
-class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
+class ModbusToggleEntity(ModbusBaseEntity, ToggleEntity, RestoreEntity):
"""Base class representing a Modbus switch."""
def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None:
@@ -371,7 +323,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
async def async_turn(self, command: int) -> None:
"""Evaluate switch result."""
result = await self._hub.async_pb_call(
- self._slave, self._address, command, self._write_type
+ self._device_address, self._address, command, self._write_type
)
if result is None:
self._attr_available = False
@@ -385,10 +337,14 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
return
if self._verify_delay:
- self._async_schedule_future_update(self._verify_delay)
+ if self._cancel_call:
+ self._cancel_call()
+ self._cancel_call = None
+ self._cancel_call = async_call_later(
+ self.hass, self._verify_delay, self.async_update
+ )
return
-
- await self._async_update_write_state()
+ await self.async_local_update(cancel_pending_update=True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Set switch off."""
@@ -402,7 +358,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
# do not allow multiple active calls to the same platform
result = await self._hub.async_pb_call(
- self._slave, self._verify_address, 1, self._verify_type
+ self._device_address, self._verify_address, 1, self._verify_type
)
if result is None:
self._attr_available = False
@@ -423,7 +379,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
"Unexpected response from modbus device slave %s register %s,"
" got 0x%2x"
),
- self._slave,
+ self._device_address,
self._verify_address,
value,
)
diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py
index 8636ef4521a..3602fbc5879 100644
--- a/homeassistant/components/modbus/fan.py
+++ b/homeassistant/components/modbus/fan.py
@@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import get_hub
from .const import CONF_FANS
-from .entity import BaseSwitch
+from .entity import ModbusToggleEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -31,7 +31,7 @@ async def async_setup_platform(
async_add_entities(ModbusFan(hass, hub, config) for config in fans)
-class ModbusFan(BaseSwitch, FanEntity):
+class ModbusFan(ModbusToggleEntity, FanEntity):
"""Class representing a Modbus fan."""
def __init__(
diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py
index 7b1035c702b..4c27ffb456b 100644
--- a/homeassistant/components/modbus/light.py
+++ b/homeassistant/components/modbus/light.py
@@ -30,7 +30,7 @@ from .const import (
LIGHT_MODBUS_SCALE_MAX,
LIGHT_MODBUS_SCALE_MIN,
)
-from .entity import BaseSwitch
+from .entity import ModbusToggleEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -49,7 +49,7 @@ async def async_setup_platform(
async_add_entities(ModbusLight(hass, hub, config) for config in lights)
-class ModbusLight(BaseSwitch, LightEntity):
+class ModbusLight(ModbusToggleEntity, LightEntity):
"""Class representing a Modbus light."""
def __init__(
@@ -64,7 +64,8 @@ class ModbusLight(BaseSwitch, LightEntity):
self._attr_color_mode = self._detect_color_mode(config)
self._attr_supported_color_modes = {self._attr_color_mode}
- # Set min/max kelvin values if the mode is COLOR_TEMP
+ self._attr_min_color_temp_kelvin: int = LIGHT_DEFAULT_MIN_KELVIN
+ self._attr_max_color_temp_kelvin: int = LIGHT_DEFAULT_MAX_KELVIN
if self._attr_color_mode == ColorMode.COLOR_TEMP:
self._attr_min_color_temp_kelvin = config.get(
CONF_MIN_TEMP, LIGHT_DEFAULT_MIN_KELVIN
@@ -116,7 +117,7 @@ class ModbusLight(BaseSwitch, LightEntity):
conv_brightness = self._convert_brightness_to_modbus(brightness)
await self._hub.async_pb_call(
- unit=self._slave,
+ unit=self._device_address,
address=self._brightness_address,
value=conv_brightness,
use_call=CALL_TYPE_WRITE_REGISTER,
@@ -132,7 +133,7 @@ class ModbusLight(BaseSwitch, LightEntity):
conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin)
await self._hub.async_pb_call(
- unit=self._slave,
+ unit=self._device_address,
address=self._color_temp_address,
value=conv_color_temp_kelvin,
use_call=CALL_TYPE_WRITE_REGISTER,
@@ -149,7 +150,7 @@ class ModbusLight(BaseSwitch, LightEntity):
if self._brightness_address:
brightness_result = await self._hub.async_pb_call(
- unit=self._slave,
+ unit=self._device_address,
value=1,
address=self._brightness_address,
use_call=CALL_TYPE_REGISTER_HOLDING,
@@ -166,7 +167,7 @@ class ModbusLight(BaseSwitch, LightEntity):
if self._color_temp_address:
color_result = await self._hub.async_pb_call(
- unit=self._slave,
+ unit=self._device_address,
value=1,
address=self._color_temp_address,
use_call=CALL_TYPE_REGISTER_HOLDING,
@@ -193,9 +194,6 @@ class ModbusLight(BaseSwitch, LightEntity):
def _convert_modbus_percent_to_temperature(self, percent: int) -> int:
"""Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К)."""
- assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance(
- self._attr_max_color_temp_kelvin, int
- )
return round(
self._attr_min_color_temp_kelvin
+ (
@@ -216,9 +214,6 @@ class ModbusLight(BaseSwitch, LightEntity):
def _convert_color_temp_to_modbus(self, kelvin: int) -> int:
"""Convert color temperature from Kelvin to the Modbus scale (0-100)."""
- assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance(
- self._attr_max_color_temp_kelvin, int
- )
return round(
LIGHT_MODBUS_SCALE_MIN
+ (kelvin - self._attr_min_color_temp_kelvin)
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index 32a043c4379..190766bf796 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
- "requirements": ["pymodbus==3.11.1"]
+ "requirements": ["pymodbus==3.11.2"]
}
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index f8604efdc2f..467ccd6d821 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -56,7 +56,7 @@ from .const import (
CONF_STOPBITS,
DEFAULT_HUB,
DEVICE_ID,
- MODBUS_DOMAIN as DOMAIN,
+ DOMAIN,
PLATFORMS,
RTUOVERTCP,
SERIAL,
@@ -169,43 +169,43 @@ async def async_modbus_setup(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus)
+ def _get_service_call_details(
+ service: ServiceCall,
+ ) -> tuple[ModbusHub, int, int]:
+ """Return the details required to process the service call."""
+ device_address = service.data.get(ATTR_SLAVE, service.data.get(ATTR_UNIT, 1))
+ address = service.data[ATTR_ADDRESS]
+ hub = hub_collect[service.data[ATTR_HUB]]
+ return (hub, device_address, address)
+
async def async_write_register(service: ServiceCall) -> None:
"""Write Modbus registers."""
- slave = 1
- if ATTR_UNIT in service.data:
- slave = int(float(service.data[ATTR_UNIT]))
+ hub, device_address, address = _get_service_call_details(service)
- if ATTR_SLAVE in service.data:
- slave = int(float(service.data[ATTR_SLAVE]))
- address = int(float(service.data[ATTR_ADDRESS]))
value = service.data[ATTR_VALUE]
- hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
if isinstance(value, list):
await hub.async_pb_call(
- slave,
- address,
- [int(float(i)) for i in value],
- CALL_TYPE_WRITE_REGISTERS,
+ device_address, address, value, CALL_TYPE_WRITE_REGISTERS
)
else:
await hub.async_pb_call(
- slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER
+ device_address, address, value, CALL_TYPE_WRITE_REGISTER
)
async def async_write_coil(service: ServiceCall) -> None:
"""Write Modbus coil."""
- slave = 1
- if ATTR_UNIT in service.data:
- slave = int(float(service.data[ATTR_UNIT]))
- if ATTR_SLAVE in service.data:
- slave = int(float(service.data[ATTR_SLAVE]))
- address = service.data[ATTR_ADDRESS]
+ hub, device_address, address = _get_service_call_details(service)
+
state = service.data[ATTR_STATE]
- hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
+
if isinstance(state, list):
- await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS)
+ await hub.async_pb_call(
+ device_address, address, state, CALL_TYPE_WRITE_COILS
+ )
else:
- await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL)
+ await hub.async_pb_call(
+ device_address, address, state, CALL_TYPE_WRITE_COIL
+ )
for x_write in (
(SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int),
@@ -253,7 +253,6 @@ class ModbusHub:
self._client: (
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
) = None
- self._lock = asyncio.Lock()
self.event_connected = asyncio.Event()
self.hass = hass
self.name = client_config[CONF_NAME]
@@ -312,15 +311,14 @@ class ModbusHub:
async def async_pb_connect(self) -> None:
"""Connect to device, async."""
while True:
- async with self._lock:
- try:
- if await self._client.connect(): # type: ignore[union-attr]
- _LOGGER.info(f"modbus {self.name} communication open")
- break
- except ModbusException as exception_error:
- self._log_error(
- f"{self.name} connect failed, please check your configuration ({exception_error!s})"
- )
+ try:
+ if await self._client.connect(): # type: ignore[union-attr]
+ _LOGGER.info(f"modbus {self.name} communication open")
+ break
+ except ModbusException as exception_error:
+ self._log_error(
+ f"{self.name} connect failed, please check your configuration ({exception_error!s})"
+ )
_LOGGER.info(
f"modbus {self.name} connect NOT a success ! retrying in {PRIMARY_RECONNECT_DELAY} seconds"
)
@@ -359,19 +357,17 @@ class ModbusHub:
async def async_close(self) -> None:
"""Disconnect client."""
+ self.event_connected.set()
if not self._connect_task.done():
self._connect_task.cancel()
- async with self._lock:
- if self._client:
- try:
- self._client.close()
- except ModbusException as exception_error:
- self._log_error(str(exception_error))
- del self._client
- self._client = None
- message = f"modbus {self.name} communication closed"
- _LOGGER.info(message)
+ if self._client:
+ try:
+ self._client.close()
+ except ModbusException as exception_error:
+ self._log_error(str(exception_error))
+ self._client = None
+ _LOGGER.info(f"modbus {self.name} communication closed")
async def low_level_pb_call(
self, slave: int | None, address: int, value: int | list[int], use_call: str
@@ -417,11 +413,9 @@ class ModbusHub:
use_call: str,
) -> ModbusPDU | None:
"""Convert async to sync pymodbus call."""
- async with self._lock:
- if not self._client:
- return None
- result = await self.low_level_pb_call(unit, address, value, use_call)
- if self._msg_wait:
- # small delay until next request/response
- await asyncio.sleep(self._msg_wait)
- return result
+ if not self._client:
+ return None
+ result = await self.low_level_pb_call(unit, address, value, use_call)
+ if self._msg_wait:
+ await asyncio.sleep(self._msg_wait)
+ return result
diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py
index b78fda022ed..185d336cc6a 100644
--- a/homeassistant/components/modbus/sensor.py
+++ b/homeassistant/components/modbus/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.helpers.update_coordinator import (
from . import get_hub
from .const import _LOGGER, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT
-from .entity import BaseStructPlatform
+from .entity import ModbusStructEntity
from .modbus import ModbusHub
PARALLEL_UPDATES = 1
@@ -56,7 +56,7 @@ async def async_setup_platform(
async_add_entities(sensors)
-class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity):
+class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
"""Modbus register sensor."""
def __init__(
@@ -107,7 +107,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity):
async def _async_update(self) -> None:
"""Update the state of the sensor."""
raw_result = await self._hub.async_pb_call(
- self._slave, self._address, self._count, self._input_type
+ self._device_address, self._address, self._count, self._input_type
)
if raw_result is None:
self._attr_available = False
@@ -181,6 +181,10 @@ class SlaveSensor(
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
result = self.coordinator.data
- self._attr_native_value = result[self._idx] if result else None
- self._attr_available = result is not None
+ if not result or self._idx >= len(result):
+ self._attr_native_value = None
+ self._attr_available = False
+ else:
+ self._attr_native_value = result[self._idx]
+ self._attr_available = True
super()._handle_coordinator_update()
diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py
index 44b0575d419..9fc3115901d 100644
--- a/homeassistant/components/modbus/switch.py
+++ b/homeassistant/components/modbus/switch.py
@@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import get_hub
-from .entity import BaseSwitch
+from .entity import ModbusToggleEntity
PARALLEL_UPDATES = 1
@@ -29,7 +29,7 @@ async def async_setup_platform(
async_add_entities(ModbusSwitch(hass, hub, config) for config in switches)
-class ModbusSwitch(BaseSwitch, SwitchEntity):
+class ModbusSwitch(ModbusToggleEntity, SwitchEntity):
"""Base class representing a Modbus switch."""
async def async_turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py
index f8f1a7450eb..fba0736c64d 100644
--- a/homeassistant/components/modbus/validators.py
+++ b/homeassistant/components/modbus/validators.py
@@ -36,7 +36,7 @@ from .const import (
CONF_VIRTUAL_COUNT,
DEFAULT_HUB,
DEFAULT_SCAN_INTERVAL,
- MODBUS_DOMAIN as DOMAIN,
+ DOMAIN,
PLATFORMS,
SERIAL,
DataType,
diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py
index e252338d4d8..372947f04c4 100644
--- a/homeassistant/components/mold_indicator/__init__.py
+++ b/homeassistant/components/mold_indicator/__init__.py
@@ -39,6 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidity
@@ -79,6 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, temp_sensor: data["entity_id"]},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
return async_sensor_updated
@@ -89,7 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -99,11 +100,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py
index d370752fff9..9d8a95c4716 100644
--- a/homeassistant/components/mold_indicator/config_flow.py
+++ b/homeassistant/components/mold_indicator/config_flow.py
@@ -100,6 +100,7 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 2
diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py
index 9d678c16874..734dbecd88b 100644
--- a/homeassistant/components/monoprice/media_player.py
+++ b/homeassistant/components/monoprice/media_player.py
@@ -89,7 +89,7 @@ async def async_setup_entry(
elif service_call.service == SERVICE_RESTORE:
entity.restore()
- @service.verify_domain_control(hass, DOMAIN)
+ @service.verify_domain_control(DOMAIN)
async def async_service_handle(service_call: core.ServiceCall) -> None:
"""Handle for services."""
entities = await platform.async_extract_from_service(service_call)
diff --git a/homeassistant/components/motioneye/services.yaml b/homeassistant/components/motioneye/services.yaml
index c5a11db8a6f..483a92635e7 100644
--- a/homeassistant/components/motioneye/services.yaml
+++ b/homeassistant/components/motioneye/services.yaml
@@ -1,8 +1,7 @@
set_text_overlay:
target:
- device:
- integration: motioneye
entity:
+ domain: camera
integration: motioneye
fields:
left_text:
@@ -48,9 +47,8 @@ set_text_overlay:
action:
target:
- device:
- integration: motioneye
entity:
+ domain: camera
integration: motioneye
fields:
action:
@@ -88,7 +86,6 @@ action:
snapshot:
target:
- device:
- integration: motioneye
entity:
+ domain: camera
integration: motioneye
diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py
index 861faa319cd..d02b286c296 100644
--- a/homeassistant/components/motionmount/select.py
+++ b/homeassistant/components/motionmount/select.py
@@ -36,6 +36,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
_attr_should_poll = True
_attr_translation_key = "motionmount_preset"
+ _name_to_index: dict[str, int]
def __init__(
self,
@@ -50,8 +51,12 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
def _update_options(self, presets: list[motionmount.Preset]) -> None:
"""Convert presets to select options."""
- options = [f"{preset.index}: {preset.name}" for preset in presets]
- options.insert(0, WALL_PRESET_NAME)
+ # Ordered list of options (wall first, then presets)
+ options = [WALL_PRESET_NAME] + [preset.name for preset in presets]
+
+ # Build mapping name → index (wall = 0)
+ self._name_to_index = {WALL_PRESET_NAME: 0}
+ self._name_to_index.update({preset.name: preset.index for preset in presets})
self._attr_options = options
@@ -123,7 +128,10 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Set the new option."""
- index = int(option[:1])
+ index = self._name_to_index.get(option)
+ if index is None:
+ raise HomeAssistantError(f"Unknown preset selected: {option}")
+
try:
await self.mm.go_to_preset(index)
except (TimeoutError, socket.gaierror) as ex:
diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json
index 2c951a7aefe..8d079dd777d 100644
--- a/homeassistant/components/motionmount/strings.json
+++ b/homeassistant/components/motionmount/strings.json
@@ -83,7 +83,7 @@
"motionmount_preset": {
"name": "Preset",
"state": {
- "0_wall": "0: Wall"
+ "0_wall": "Wall"
}
}
}
diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py
index f0d000f79db..89857efc149 100644
--- a/homeassistant/components/mqtt/abbreviations.py
+++ b/homeassistant/components/mqtt/abbreviations.py
@@ -41,6 +41,7 @@ ABBREVIATIONS = {
"curr_hum_tpl": "current_humidity_template",
"curr_temp_t": "current_temperature_topic",
"curr_temp_tpl": "current_temperature_template",
+ "def_ent_id": "default_entity_id",
"dev": "device",
"dev_cla": "device_class",
"dir_cmd_t": "direction_command_topic",
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 64b1a6b05fa..72b92cdcb9d 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -7,10 +7,7 @@ import logging
import voluptuous as vol
from homeassistant.components import alarm_control_panel as alarm
-from homeassistant.components.alarm_control_panel import (
- AlarmControlPanelEntityFeature,
- AlarmControlPanelState,
-)
+from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
@@ -21,12 +18,33 @@ from homeassistant.helpers.typing import ConfigType
from . import subscription
from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA
from .const import (
+ ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
+ CONF_CODE_ARM_REQUIRED,
+ CONF_CODE_DISARM_REQUIRED,
+ CONF_CODE_TRIGGER_REQUIRED,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
+ CONF_PAYLOAD_ARM_AWAY,
+ CONF_PAYLOAD_ARM_CUSTOM_BYPASS,
+ CONF_PAYLOAD_ARM_HOME,
+ CONF_PAYLOAD_ARM_NIGHT,
+ CONF_PAYLOAD_ARM_VACATION,
+ CONF_PAYLOAD_DISARM,
+ CONF_PAYLOAD_TRIGGER,
CONF_RETAIN,
CONF_STATE_TOPIC,
CONF_SUPPORTED_FEATURES,
+ DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE,
+ DEFAULT_PAYLOAD_ARM_AWAY,
+ DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS,
+ DEFAULT_PAYLOAD_ARM_HOME,
+ DEFAULT_PAYLOAD_ARM_NIGHT,
+ DEFAULT_PAYLOAD_ARM_VACATION,
+ DEFAULT_PAYLOAD_DISARM,
+ DEFAULT_PAYLOAD_TRIGGER,
PAYLOAD_NONE,
+ REMOTE_CODE,
+ REMOTE_CODE_TEXT,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage
@@ -37,26 +55,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-_SUPPORTED_FEATURES = {
- "arm_home": AlarmControlPanelEntityFeature.ARM_HOME,
- "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY,
- "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT,
- "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION,
- "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
- "trigger": AlarmControlPanelEntityFeature.TRIGGER,
-}
-
-CONF_CODE_ARM_REQUIRED = "code_arm_required"
-CONF_CODE_DISARM_REQUIRED = "code_disarm_required"
-CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required"
-CONF_PAYLOAD_DISARM = "payload_disarm"
-CONF_PAYLOAD_ARM_HOME = "payload_arm_home"
-CONF_PAYLOAD_ARM_AWAY = "payload_arm_away"
-CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night"
-CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation"
-CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass"
-CONF_PAYLOAD_TRIGGER = "payload_trigger"
-
MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset(
{
alarm.ATTR_CHANGED_BY,
@@ -65,44 +63,40 @@ MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset(
}
)
-DEFAULT_COMMAND_TEMPLATE = "{{action}}"
-DEFAULT_ARM_NIGHT = "ARM_NIGHT"
-DEFAULT_ARM_VACATION = "ARM_VACATION"
-DEFAULT_ARM_AWAY = "ARM_AWAY"
-DEFAULT_ARM_HOME = "ARM_HOME"
-DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS"
-DEFAULT_DISARM = "DISARM"
-DEFAULT_TRIGGER = "TRIGGER"
DEFAULT_NAME = "MQTT Alarm"
-REMOTE_CODE = "REMOTE_CODE"
-REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT"
-
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
{
- vol.Optional(CONF_SUPPORTED_FEATURES, default=list(_SUPPORTED_FEATURES)): [
- vol.In(_SUPPORTED_FEATURES)
- ],
+ vol.Optional(
+ CONF_SUPPORTED_FEATURES,
+ default=list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES),
+ ): [vol.In(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES)],
vol.Optional(CONF_CODE): cv.string,
vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean,
vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean,
vol.Optional(CONF_CODE_TRIGGER_REQUIRED, default=True): cv.boolean,
vol.Optional(
- CONF_COMMAND_TEMPLATE, default=DEFAULT_COMMAND_TEMPLATE
+ CONF_COMMAND_TEMPLATE, default=DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE
): cv.template,
vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
- vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string,
- vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string,
- vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string,
vol.Optional(
- CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_ARM_VACATION
+ CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_PAYLOAD_ARM_AWAY
): cv.string,
vol.Optional(
- CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS
+ CONF_PAYLOAD_ARM_HOME, default=DEFAULT_PAYLOAD_ARM_HOME
): cv.string,
- vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
- vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_TRIGGER): cv.string,
+ vol.Optional(
+ CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_PAYLOAD_ARM_NIGHT
+ ): cv.string,
+ vol.Optional(
+ CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_PAYLOAD_ARM_VACATION
+ ): cv.string,
+ vol.Optional(
+ CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS
+ ): cv.string,
+ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_PAYLOAD_DISARM): cv.string,
+ vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_PAYLOAD_TRIGGER): cv.string,
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
@@ -152,7 +146,9 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity):
).async_render
for feature in self._config[CONF_SUPPORTED_FEATURES]:
- self._attr_supported_features |= _SUPPORTED_FEATURES[feature]
+ self._attr_supported_features |= ALARM_CONTROL_PANEL_SUPPORTED_FEATURES[
+ feature
+ ]
if (code := self._config.get(CONF_CODE)) is None:
self._attr_code_format = None
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index 03f758dbdce..d115c13d0e7 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -39,6 +39,7 @@ from homeassistant.components.climate import (
from homeassistant.components.cover import CoverDeviceClass
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
+from homeassistant.components.image import DEFAULT_CONTENT_TYPE
from homeassistant.components.light import (
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
@@ -65,12 +66,14 @@ from homeassistant.config_entries import (
from homeassistant.const import (
ATTR_CONFIGURATION_URL,
ATTR_HW_VERSION,
+ ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_MODEL_ID,
ATTR_NAME,
ATTR_SW_VERSION,
CONF_BRIGHTNESS,
CONF_CLIENT_ID,
+ CONF_CODE,
CONF_DEVICE,
CONF_DEVICE_CLASS,
CONF_DISCOVERY,
@@ -129,6 +132,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter
from .addon import get_addon_manager
from .client import MqttClientSetup
from .const import (
+ ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
ATTR_PAYLOAD,
ATTR_QOS,
ATTR_RETAIN,
@@ -149,6 +153,10 @@ from .const import (
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
+ CONF_CODE_ARM_REQUIRED,
+ CONF_CODE_DISARM_REQUIRED,
+ CONF_CODE_FORMAT,
+ CONF_CODE_TRIGGER_REQUIRED,
CONF_COLOR_MODE_STATE_TOPIC,
CONF_COLOR_MODE_VALUE_TEMPLATE,
CONF_COLOR_TEMP_COMMAND_TEMPLATE,
@@ -161,6 +169,7 @@ from .const import (
CONF_COMMAND_ON_TEMPLATE,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
+ CONF_CONTENT_TYPE,
CONF_CURRENT_HUMIDITY_TEMPLATE,
CONF_CURRENT_HUMIDITY_TOPIC,
CONF_CURRENT_TEMP_TEMPLATE,
@@ -199,6 +208,8 @@ from .const import (
CONF_HUMIDITY_MIN,
CONF_HUMIDITY_STATE_TEMPLATE,
CONF_HUMIDITY_STATE_TOPIC,
+ CONF_IMAGE_ENCODING,
+ CONF_IMAGE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX_KELVIN,
@@ -215,17 +226,26 @@ from .const import (
CONF_OSCILLATION_COMMAND_TOPIC,
CONF_OSCILLATION_STATE_TOPIC,
CONF_OSCILLATION_VALUE_TEMPLATE,
+ CONF_PAYLOAD_ARM_AWAY,
+ CONF_PAYLOAD_ARM_CUSTOM_BYPASS,
+ CONF_PAYLOAD_ARM_HOME,
+ CONF_PAYLOAD_ARM_NIGHT,
+ CONF_PAYLOAD_ARM_VACATION,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_CLOSE,
+ CONF_PAYLOAD_LOCK,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_PAYLOAD_OPEN,
CONF_PAYLOAD_OSCILLATION_OFF,
CONF_PAYLOAD_OSCILLATION_ON,
CONF_PAYLOAD_PRESS,
+ CONF_PAYLOAD_RESET,
CONF_PAYLOAD_RESET_PERCENTAGE,
CONF_PAYLOAD_RESET_PRESET_MODE,
CONF_PAYLOAD_STOP,
CONF_PAYLOAD_STOP_TILT,
+ CONF_PAYLOAD_TRIGGER,
+ CONF_PAYLOAD_UNLOCK,
CONF_PERCENTAGE_COMMAND_TEMPLATE,
CONF_PERCENTAGE_COMMAND_TOPIC,
CONF_PERCENTAGE_STATE_TOPIC,
@@ -262,15 +282,21 @@ from .const import (
CONF_SPEED_RANGE_MIN,
CONF_STATE_CLOSED,
CONF_STATE_CLOSING,
+ CONF_STATE_JAMMED,
+ CONF_STATE_LOCKED,
+ CONF_STATE_LOCKING,
CONF_STATE_OFF,
CONF_STATE_ON,
CONF_STATE_OPEN,
CONF_STATE_OPENING,
CONF_STATE_STOPPED,
CONF_STATE_TOPIC,
+ CONF_STATE_UNLOCKED,
+ CONF_STATE_UNLOCKING,
CONF_STATE_VALUE_TEMPLATE,
CONF_SUGGESTED_DISPLAY_PRECISION,
CONF_SUPPORTED_COLOR_MODES,
+ CONF_SUPPORTED_FEATURES,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_LIST,
@@ -309,6 +335,8 @@ from .const import (
CONF_TLS_INSECURE,
CONF_TRANSITION,
CONF_TRANSPORT,
+ CONF_URL_TEMPLATE,
+ CONF_URL_TOPIC,
CONF_WHITE_COMMAND_TOPIC,
CONF_WHITE_SCALE,
CONF_WILL_MESSAGE,
@@ -320,14 +348,21 @@ from .const import (
CONF_XY_VALUE_TEMPLATE,
CONFIG_ENTRY_MINOR_VERSION,
CONFIG_ENTRY_VERSION,
+ DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE,
DEFAULT_BIRTH,
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
DEFAULT_ON_COMMAND_TYPE,
+ DEFAULT_PAYLOAD_ARM_AWAY,
+ DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS,
+ DEFAULT_PAYLOAD_ARM_HOME,
+ DEFAULT_PAYLOAD_ARM_NIGHT,
+ DEFAULT_PAYLOAD_ARM_VACATION,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_CLOSE,
+ DEFAULT_PAYLOAD_LOCK,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
DEFAULT_PAYLOAD_OFF,
DEFAULT_PAYLOAD_ON,
@@ -337,6 +372,8 @@ from .const import (
DEFAULT_PAYLOAD_PRESS,
DEFAULT_PAYLOAD_RESET,
DEFAULT_PAYLOAD_STOP,
+ DEFAULT_PAYLOAD_TRIGGER,
+ DEFAULT_PAYLOAD_UNLOCK,
DEFAULT_PORT,
DEFAULT_POSITION_CLOSED,
DEFAULT_POSITION_OPEN,
@@ -345,7 +382,12 @@ from .const import (
DEFAULT_QOS,
DEFAULT_SPEED_RANGE_MAX,
DEFAULT_SPEED_RANGE_MIN,
+ DEFAULT_STATE_JAMMED,
+ DEFAULT_STATE_LOCKED,
+ DEFAULT_STATE_LOCKING,
DEFAULT_STATE_STOPPED,
+ DEFAULT_STATE_UNLOCKED,
+ DEFAULT_STATE_UNLOCKING,
DEFAULT_TILT_CLOSED_POSITION,
DEFAULT_TILT_MAX,
DEFAULT_TILT_MIN,
@@ -354,6 +396,8 @@ from .const import (
DEFAULT_WILL,
DEFAULT_WS_PATH,
DOMAIN,
+ REMOTE_CODE,
+ REMOTE_CODE_TEXT,
SUPPORTED_PROTOCOLS,
TRANSPORT_TCP,
TRANSPORT_WEBSOCKETS,
@@ -384,21 +428,73 @@ ADVANCED_OPTIONS = "advanced_options"
SET_CA_CERT = "set_ca_cert"
SET_CLIENT_CERT = "set_client_cert"
+CA_VERIFICATION_MODES = [
+ "off",
+ "auto",
+ "custom",
+]
+
+SUBENTRY_PLATFORMS = [
+ Platform.ALARM_CONTROL_PANEL,
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.CLIMATE,
+ Platform.COVER,
+ Platform.FAN,
+ Platform.IMAGE,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.NOTIFY,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
+
+_CODE_VALIDATION_MODE = {
+ "remote_code": REMOTE_CODE,
+ "remote_code_text": REMOTE_CODE_TEXT,
+}
+EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY}
+PWD_NOT_CHANGED = "__**password_not_changed**__"
+
+# Common selectors
BOOLEAN_SELECTOR = BooleanSelector()
+TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
+TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True))
TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
TEXT_SELECTOR_READ_ONLY = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True)
)
-URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL))
-PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
-PORT_SELECTOR = vol.All(
- NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)),
- vol.Coerce(int),
+OPTIONS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[],
+ custom_value=True,
+ multiple=True,
+ )
)
PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD))
QOS_SELECTOR = NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)
)
+URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL))
+
+# Config flow specific selectors
+BROKER_VERIFICATION_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=CA_VERIFICATION_MODES,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=SET_CA_CERT,
+ )
+)
+# mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html
+CA_CERT_UPLOAD_SELECTOR = FileSelector(
+ FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert")
+)
+CERT_KEY_UPLOAD_SELECTOR = FileSelector(
+ FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8")
+)
+CERT_UPLOAD_SELECTOR = FileSelector(
+ FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert")
+)
KEEPALIVE_SELECTOR = vol.All(
NumberSelector(
NumberSelectorConfig(
@@ -407,12 +503,17 @@ KEEPALIVE_SELECTOR = vol.All(
),
vol.Coerce(int),
)
+PORT_SELECTOR = vol.All(
+ NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)),
+ vol.Coerce(int),
+)
PROTOCOL_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=SUPPORTED_PROTOCOLS,
mode=SelectSelectorMode.DROPDOWN,
)
)
+PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
SUPPORTED_TRANSPORTS = [
SelectOptionDict(value=TRANSPORT_TCP, label="TCP"),
SelectOptionDict(value=TRANSPORT_WEBSOCKETS, label="WebSocket"),
@@ -426,52 +527,16 @@ TRANSPORT_SELECTOR = SelectSelector(
WS_HEADERS_SELECTOR = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
)
-CA_VERIFICATION_MODES = [
- "off",
- "auto",
- "custom",
-]
-BROKER_VERIFICATION_SELECTOR = SelectSelector(
+
+# MQTT device subentry selectors
+ENTITY_CATEGORY_SELECTOR = SelectSelector(
SelectSelectorConfig(
- options=CA_VERIFICATION_MODES,
+ options=[category.value for category in EntityCategory],
mode=SelectSelectorMode.DROPDOWN,
- translation_key=SET_CA_CERT,
+ translation_key=CONF_ENTITY_CATEGORY,
+ sort=True,
)
)
-
-# mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html
-CA_CERT_UPLOAD_SELECTOR = FileSelector(
- FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert")
-)
-CERT_UPLOAD_SELECTOR = FileSelector(
- FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert")
-)
-KEY_UPLOAD_SELECTOR = FileSelector(
- FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8")
-)
-
-# Subentry selectors
-SUBENTRY_PLATFORMS = [
- Platform.BINARY_SENSOR,
- Platform.BUTTON,
- Platform.CLIMATE,
- Platform.COVER,
- Platform.FAN,
- Platform.LIGHT,
- Platform.NOTIFY,
- Platform.SENSOR,
- Platform.SWITCH,
-]
-SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[platform.value for platform in SUBENTRY_PLATFORMS],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key=CONF_PLATFORM,
- )
-)
-TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
-TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True))
-
SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_AVAILABILITY_TOPIC): TEXT_SELECTOR,
@@ -484,22 +549,32 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
): TEXT_SELECTOR,
}
)
-ENTITY_CATEGORY_SELECTOR = SelectSelector(
+SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
SelectSelectorConfig(
- options=[category.value for category in EntityCategory],
+ options=[platform.value for platform in SUBENTRY_PLATFORMS],
mode=SelectSelectorMode.DROPDOWN,
- translation_key=CONF_ENTITY_CATEGORY,
- sort=True,
+ translation_key=CONF_PLATFORM,
)
)
+SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
+)
+TIMEOUT_SELECTOR = NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
+)
-# Sensor specific selectors
-SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
+# Entity platform specific selectors
+ALARM_CONTROL_PANEL_FEATURES_SELECTOR = SelectSelector(
SelectSelectorConfig(
- options=[device_class.value for device_class in SensorDeviceClass],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key="device_class_sensor",
- sort=True,
+ options=list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES),
+ multiple=True,
+ translation_key="alarm_control_panel_features",
+ )
+)
+ALARM_CONTROL_PANEL_CODE_MODE = SelectSelector(
+ SelectSelectorConfig(
+ options=["local_code", "remote_code", "remote_code_text"],
+ translation_key="alarm_control_panel_code_mode",
)
)
BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
@@ -510,6 +585,133 @@ BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
sort=True,
)
)
+BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in ButtonDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_button",
+ sort=True,
+ )
+)
+CLIMATE_MODE_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["auto", "off", "cool", "heat", "dry", "fan_only"],
+ multiple=True,
+ translation_key="climate_modes",
+ )
+)
+COVER_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in CoverDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_cover",
+ sort=True,
+ )
+)
+FAN_SPEED_RANGE_MIN_SELECTOR = vol.All(
+ NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1)),
+ vol.Coerce(int),
+)
+FAN_SPEED_RANGE_MAX_SELECTOR = vol.All(
+ NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=2)),
+ vol.Coerce(int),
+)
+FLASH_TIME_SELECTOR = NumberSelector(
+ NumberSelectorConfig(
+ mode=NumberSelectorMode.BOX,
+ min=1,
+ )
+)
+HUMIDITY_SELECTOR = vol.All(
+ NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=100, step=1)
+ ),
+ vol.Coerce(int),
+)
+IMAGE_CONTENT_TYPE_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(
+ value="image/jpeg", label="Joint Photographic Expert Group image (JPEG)"
+ ),
+ SelectOptionDict(
+ value="image/png", label="Portable Network Graphics (PNG)"
+ ),
+ SelectOptionDict(
+ value="image/apng", label="Animated Portable Network Graphics (APNG)"
+ ),
+ SelectOptionDict(value="image/avif", label="AV1 Image File Format (AVIF)"),
+ SelectOptionDict(
+ value="image/gif", label="Graphics Interchange Format (GIF)"
+ ),
+ SelectOptionDict(
+ value="image/svg+xml", label="Scalable Vector Graphics (SVG)"
+ ),
+ SelectOptionDict(value="image/webp", label="Web Picture format (WEBP)"),
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+)
+IMAGE_ENCODING_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["raw", "b64"],
+ translation_key="image_encoding",
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+)
+IMAGE_PROCESSING_MODE_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["image_url", "image_data"],
+ translation_key="image_processing_mode",
+ )
+)
+KELVIN_SELECTOR = NumberSelector(
+ NumberSelectorConfig(
+ mode=NumberSelectorMode.BOX,
+ min=1000,
+ max=10000,
+ step="any",
+ unit_of_measurement="K",
+ )
+)
+LIGHT_SCHEMA_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["basic", "json", "template"],
+ translation_key="light_schema",
+ )
+)
+ON_COMMAND_TYPE_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=VALUES_ON_COMMAND_TYPE,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_ON_COMMAND_TYPE,
+ sort=True,
+ )
+)
+POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX))
+PRECISION_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["1.0", "0.5", "0.1"],
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+)
+PRESET_MODES_SELECTOR = OPTIONS_SELECTOR
+SCALE_SELECTOR = NumberSelector(
+ NumberSelectorConfig(
+ mode=NumberSelectorMode.BOX,
+ min=1,
+ max=255,
+ step=1,
+ )
+)
+SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in SensorDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_sensor",
+ sort=True,
+ )
+)
SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[EntityCategory.DIAGNOSTIC.value],
@@ -518,23 +720,6 @@ SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector(
sort=True,
)
)
-
-BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[device_class.value for device_class in ButtonDeviceClass],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key="device_class_button",
- sort=True,
- )
-)
-COVER_DEVICE_CLASS_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[device_class.value for device_class in CoverDeviceClass],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key="device_class_cover",
- sort=True,
- )
-)
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
@@ -542,28 +727,107 @@ SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
translation_key=CONF_STATE_CLASS,
)
)
-OPTIONS_SELECTOR = SelectSelector(
+SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
SelectSelectorConfig(
- options=[],
- custom_value=True,
+ options=[platform.value for platform in VALID_COLOR_MODES],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_SUPPORTED_COLOR_MODES,
multiple=True,
+ sort=True,
)
)
-SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
- NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
+SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in SwitchDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_switch",
+ )
)
-TIMEOUT_SELECTOR = NumberSelector(
- NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
+TARGET_TEMPERATURE_FEATURE_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=["single", "high_low", "none"],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="target_temperature_feature",
+ )
+)
+TEMPERATURE_UNIT_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(value="C", label="°C"),
+ SelectOptionDict(value="F", label="°F"),
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ )
)
-# Climate specific selectors
-CLIMATE_MODE_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=["auto", "off", "cool", "heat", "dry", "fan_only"],
- multiple=True,
- translation_key="climate_modes",
+
+@callback
+def configured_target_temperature_feature(config: dict[str, Any]) -> str:
+ """Calculate current target temperature feature from config."""
+ if (
+ config == {CONF_PLATFORM: Platform.CLIMATE.value}
+ or CONF_TEMP_COMMAND_TOPIC in config
+ ):
+ # default to single on initial set
+ return "single"
+ if CONF_TEMP_HIGH_COMMAND_TOPIC in config:
+ return "high_low"
+ return "none"
+
+
+@callback
+def default_alarm_control_panel_code(config: dict[str, Any]) -> str:
+ """Return alarm control panel code based on the stored code and code mode."""
+ code: str
+ if config["alarm_control_panel_code_mode"] in _CODE_VALIDATION_MODE:
+ # Return magic value for remote code validation
+ return _CODE_VALIDATION_MODE[config["alarm_control_panel_code_mode"]]
+ if (code := config.get(CONF_CODE, "")) in _CODE_VALIDATION_MODE.values():
+ # Remove magic value for remote code validation
+ return ""
+
+ return code
+
+
+@callback
+def default_precision(config: dict[str, Any]) -> str:
+ """Return the thermostat precision for system default unit."""
+
+ return str(
+ config.get(
+ CONF_PRECISION,
+ 0.1
+ if cv.temperature_unit(config[CONF_TEMPERATURE_UNIT])
+ is UnitOfTemperature.CELSIUS
+ else 1.0,
+ )
)
-)
+
+
+@callback
+def no_empty_list(value: list[Any]) -> list[Any]:
+ """Validate a selector returns at least one item."""
+ if not value:
+ raise vol.Invalid("empty_list_not_allowed")
+ return value
+
+
+@callback
+def temperature_default_from_celsius_to_system_default(
+ value: float,
+) -> Callable[[dict[str, Any]], int]:
+ """Return temperature in Celsius in system default unit."""
+
+ def _default(config: dict[str, Any]) -> int:
+ return round(
+ TemperatureConverter.convert(
+ value,
+ UnitOfTemperature.CELSIUS,
+ cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]),
+ )
+ )
+
+ return _default
@callback
@@ -593,159 +857,61 @@ def temperature_step_selector(config: dict[str, Any]) -> Selector:
)
-TEMPERATURE_UNIT_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[
- SelectOptionDict(value="C", label="°C"),
- SelectOptionDict(value="F", label="°F"),
- ],
- mode=SelectSelectorMode.DROPDOWN,
- )
-)
-PRECISION_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=["1.0", "0.5", "0.1"],
- mode=SelectSelectorMode.DROPDOWN,
- )
-)
-
-# Cover specific selectors
-POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX))
-
-# Fan specific selectors
-FAN_SPEED_RANGE_MIN_SELECTOR = vol.All(
- NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1)),
- vol.Coerce(int),
-)
-FAN_SPEED_RANGE_MAX_SELECTOR = vol.All(
- NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=2)),
- vol.Coerce(int),
-)
-PRESET_MODES_SELECTOR = OPTIONS_SELECTOR
-
-# Switch specific selectors
-SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[device_class.value for device_class in SwitchDeviceClass],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key="device_class_switch",
- )
-)
-
-# Light specific selectors
-LIGHT_SCHEMA_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=["basic", "json", "template"],
- translation_key="light_schema",
- )
-)
-KELVIN_SELECTOR = NumberSelector(
- NumberSelectorConfig(
- mode=NumberSelectorMode.BOX,
- min=1000,
- max=10000,
- step="any",
- unit_of_measurement="K",
- )
-)
-SCALE_SELECTOR = NumberSelector(
- NumberSelectorConfig(
- mode=NumberSelectorMode.BOX,
- min=1,
- max=255,
- step=1,
- )
-)
-FLASH_TIME_SELECTOR = NumberSelector(
- NumberSelectorConfig(
- mode=NumberSelectorMode.BOX,
- min=1,
- )
-)
-ON_COMMAND_TYPE_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=VALUES_ON_COMMAND_TYPE,
- mode=SelectSelectorMode.DROPDOWN,
- translation_key=CONF_ON_COMMAND_TYPE,
- sort=True,
- )
-)
-SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=[platform.value for platform in VALID_COLOR_MODES],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key=CONF_SUPPORTED_COLOR_MODES,
- multiple=True,
- sort=True,
- )
-)
-
-EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY}
-
-
-# Target temperature feature selector
@callback
-def configured_target_temperature_feature(config: dict[str, Any]) -> str:
- """Calculate current target temperature feature from config."""
- if (
- config == {CONF_PLATFORM: Platform.CLIMATE.value}
- or CONF_TEMP_COMMAND_TOPIC in config
- ):
- # default to single on initial set
- return "single"
- if CONF_TEMP_HIGH_COMMAND_TOPIC in config:
- return "high_low"
- return "none"
+def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
+ """Return a context based unit of measurement selector."""
-
-TARGET_TEMPERATURE_FEATURE_SELECTOR = SelectSelector(
- SelectSelectorConfig(
- options=["single", "high_low", "none"],
- mode=SelectSelectorMode.DROPDOWN,
- translation_key="target_temperature_feature",
- )
-)
-HUMIDITY_SELECTOR = vol.All(
- NumberSelector(
- NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=100, step=1)
- ),
- vol.Coerce(int),
-)
-
-
-@callback
-def temperature_default_from_celsius_to_system_default(
- value: float,
-) -> Callable[[dict[str, Any]], int]:
- """Return temperature in Celsius in system default unit."""
-
- def _default(config: dict[str, Any]) -> int:
- return round(
- TemperatureConverter.convert(
- value,
- UnitOfTemperature.CELSIUS,
- cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]),
+ if (state_class := user_data.get(CONF_STATE_CLASS)) in STATE_CLASS_UNITS:
+ return SelectSelector(
+ SelectSelectorConfig(
+ options=[str(uom) for uom in STATE_CLASS_UNITS[state_class]],
+ sort=True,
+ custom_value=True,
)
)
- return _default
-
-
-@callback
-def default_precision(config: dict[str, Any]) -> str:
- """Return the thermostat precision for system default unit."""
-
- return str(
- config.get(
- CONF_PRECISION,
- 0.1
- if cv.temperature_unit(config[CONF_TEMPERATURE_UNIT])
- is UnitOfTemperature.CELSIUS
- else 1.0,
+ if (
+ device_class := user_data.get(CONF_DEVICE_CLASS)
+ ) is None or device_class not in DEVICE_CLASS_UNITS:
+ return TEXT_SELECTOR
+ return SelectSelector(
+ SelectSelectorConfig(
+ options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]],
+ sort=True,
+ custom_value=True,
)
)
+@callback
+def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
+ """Run validator, then return the unmodified input."""
+
+ def _validate(value: Any) -> Any:
+ validator(value)
+ return value
+
+ return _validate
+
+
+@callback
+def validate_field(
+ field: str,
+ validator: Callable[..., Any],
+ user_input: dict[str, Any] | None,
+ errors: dict[str, str],
+ error: str,
+) -> None:
+ """Validate a single field."""
+ if user_input is None or field not in user_input or validator is None:
+ return
+ try:
+ user_input[field] = validator(user_input[field])
+ except (ValueError, vol.Error, vol.Invalid):
+ errors[field] = error
+
+
+# Entity platform config validation
@callback
def validate_climate_platform_config(config: dict[str, Any]) -> dict[str, str]:
"""Validate the climate platform options."""
@@ -829,6 +995,17 @@ def validate_fan_platform_config(config: dict[str, Any]) -> dict[str, str]:
return errors
+@callback
+def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
+ """Validate MQTT light configuration."""
+ errors: dict[str, Any] = {}
+ if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
+ CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
+ ):
+ errors["advanced_settings"] = "max_below_min_kelvin"
+ return errors
+
+
@callback
def validate_sensor_platform_config(
config: dict[str, Any],
@@ -877,23 +1054,23 @@ def validate_sensor_platform_config(
return errors
-@callback
-def no_empty_list(value: list[Any]) -> list[Any]:
- """Validate a selector returns at least one item."""
- if not value:
- raise vol.Invalid("empty_list_not_allowed")
- return value
-
-
-@callback
-def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
- """Run validator, then return the unmodified input."""
-
- def _validate(value: Any) -> Any:
- validator(value)
- return value
-
- return _validate
+ENTITY_CONFIG_VALIDATOR: dict[
+ str,
+ Callable[[dict[str, Any]], dict[str, str]] | None,
+] = {
+ Platform.ALARM_CONTROL_PANEL: None,
+ Platform.BINARY_SENSOR.value: None,
+ Platform.BUTTON.value: None,
+ Platform.CLIMATE.value: validate_climate_platform_config,
+ Platform.COVER.value: validate_cover_platform_config,
+ Platform.FAN.value: validate_fan_platform_config,
+ Platform.IMAGE.value: None,
+ Platform.LIGHT.value: validate_light_platform_config,
+ Platform.LOCK.value: None,
+ Platform.NOTIFY.value: None,
+ Platform.SENSOR.value: validate_sensor_platform_config,
+ Platform.SWITCH.value: None,
+}
@dataclass(frozen=True, kw_only=True)
@@ -908,6 +1085,7 @@ class PlatformField:
vol.UNDEFINED
)
is_schema_default: bool = False
+ include_in_config: bool = False
exclude_from_reconfig: bool = False
exclude_from_config: bool = False
conditions: tuple[dict[str, Any], ...] | None = None
@@ -915,43 +1093,6 @@ class PlatformField:
section: str | None = None
-@callback
-def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
- """Return a context based unit of measurement selector."""
-
- if (state_class := user_data.get(CONF_STATE_CLASS)) in STATE_CLASS_UNITS:
- return SelectSelector(
- SelectSelectorConfig(
- options=[str(uom) for uom in STATE_CLASS_UNITS[state_class]],
- sort=True,
- custom_value=True,
- )
- )
-
- if (
- device_class := user_data.get(CONF_DEVICE_CLASS)
- ) is None or device_class not in DEVICE_CLASS_UNITS:
- return TEXT_SELECTOR
- return SelectSelector(
- SelectSelectorConfig(
- options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]],
- sort=True,
- custom_value=True,
- )
- )
-
-
-@callback
-def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
- """Validate MQTT light configuration."""
- errors: dict[str, Any] = {}
- if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
- CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
- ):
- errors["advanced_settings"] = "max_below_min_kelvin"
- return errors
-
-
COMMON_ENTITY_FIELDS: dict[str, PlatformField] = {
CONF_PLATFORM: PlatformField(
selector=SUBENTRY_PLATFORM_SELECTOR,
@@ -968,7 +1109,6 @@ COMMON_ENTITY_FIELDS: dict[str, PlatformField] = {
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
),
}
-
SHARED_PLATFORM_ENTITY_FIELDS: dict[str, PlatformField] = {
CONF_ENTITY_CATEGORY: PlatformField(
selector=ENTITY_CATEGORY_SELECTOR,
@@ -976,8 +1116,24 @@ SHARED_PLATFORM_ENTITY_FIELDS: dict[str, PlatformField] = {
default=None,
),
}
-
PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
+ Platform.ALARM_CONTROL_PANEL.value: {
+ CONF_SUPPORTED_FEATURES: PlatformField(
+ selector=ALARM_CONTROL_PANEL_FEATURES_SELECTOR,
+ required=True,
+ default=lambda config: config.get(
+ CONF_SUPPORTED_FEATURES, list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES)
+ ),
+ ),
+ "alarm_control_panel_code_mode": PlatformField(
+ selector=ALARM_CONTROL_PANEL_CODE_MODE,
+ required=True,
+ exclude_from_config=True,
+ default=lambda config: config[CONF_CODE].lower()
+ if config.get(CONF_CODE) in (REMOTE_CODE, REMOTE_CODE_TEXT)
+ else "local_code",
+ ),
+ },
Platform.BINARY_SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR,
@@ -1099,6 +1255,33 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)),
),
},
+ Platform.IMAGE.value: {
+ "image_processing_mode": PlatformField(
+ selector=IMAGE_PROCESSING_MODE_SELECTOR,
+ required=True,
+ exclude_from_config=True,
+ default=(
+ lambda config: "image_url"
+ if config.get(CONF_IMAGE_TOPIC) is None
+ else "image_data"
+ ),
+ )
+ },
+ Platform.LIGHT.value: {
+ CONF_SCHEMA: PlatformField(
+ selector=LIGHT_SCHEMA_SELECTOR,
+ required=True,
+ default="basic",
+ exclude_from_reconfig=True,
+ ),
+ CONF_COLOR_TEMP_KELVIN: PlatformField(
+ selector=BOOLEAN_SELECTOR,
+ required=True,
+ default=True,
+ is_schema_default=True,
+ ),
+ },
+ Platform.LOCK.value: {},
Platform.NOTIFY.value: {},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
@@ -1134,22 +1317,94 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False
),
},
- Platform.LIGHT.value: {
- CONF_SCHEMA: PlatformField(
- selector=LIGHT_SCHEMA_SELECTOR,
+}
+PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
+ Platform.ALARM_CONTROL_PANEL: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
required=True,
- default="basic",
- exclude_from_reconfig=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
),
- CONF_COLOR_TEMP_KELVIN: PlatformField(
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ default=DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_CODE: PlatformField(
+ selector=PASSWORD_SELECTOR,
+ required=True,
+ include_in_config=True,
+ default=default_alarm_control_panel_code,
+ conditions=({"alarm_control_panel_code_mode": "local_code"},),
+ ),
+ CONF_CODE_ARM_REQUIRED: PlatformField(
selector=BOOLEAN_SELECTOR,
required=True,
default=True,
- is_schema_default=True,
+ ),
+ CONF_CODE_DISARM_REQUIRED: PlatformField(
+ selector=BOOLEAN_SELECTOR,
+ required=True,
+ default=True,
+ ),
+ CONF_CODE_TRIGGER_REQUIRED: PlatformField(
+ selector=BOOLEAN_SELECTOR,
+ required=True,
+ default=True,
+ ),
+ CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ CONF_PAYLOAD_ARM_HOME: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ARM_HOME,
+ section="alarm_control_panel_payload_settings",
+ ),
+ CONF_PAYLOAD_ARM_AWAY: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ARM_AWAY,
+ section="alarm_control_panel_payload_settings",
+ ),
+ CONF_PAYLOAD_ARM_NIGHT: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ARM_NIGHT,
+ section="alarm_control_panel_payload_settings",
+ ),
+ CONF_PAYLOAD_ARM_VACATION: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ARM_VACATION,
+ section="alarm_control_panel_payload_settings",
+ ),
+ CONF_PAYLOAD_ARM_CUSTOM_BYPASS: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS,
+ section="alarm_control_panel_payload_settings",
+ ),
+ CONF_PAYLOAD_TRIGGER: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_TRIGGER,
+ section="alarm_control_panel_payload_settings",
),
},
-}
-PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
Platform.BINARY_SENSOR.value: {
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
@@ -2095,93 +2350,39 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
conditions=({"fan_feature_direction": True},),
),
},
- Platform.NOTIFY.value: {
- CONF_COMMAND_TOPIC: PlatformField(
- selector=TEXT_SELECTOR,
- required=True,
- validator=valid_publish_topic,
- error="invalid_publish_topic",
- ),
- CONF_COMMAND_TEMPLATE: PlatformField(
- selector=TEMPLATE_SELECTOR,
- required=False,
- validator=validate(cv.template),
- error="invalid_template",
- ),
- CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
- },
- Platform.SENSOR.value: {
- CONF_STATE_TOPIC: PlatformField(
+ Platform.IMAGE.value: {
+ CONF_IMAGE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
+ conditions=({"image_processing_mode": "image_data"},),
),
- CONF_VALUE_TEMPLATE: PlatformField(
- selector=TEMPLATE_SELECTOR,
+ CONF_CONTENT_TYPE: PlatformField(
+ selector=IMAGE_CONTENT_TYPE_SELECTOR,
+ required=True,
+ default=DEFAULT_CONTENT_TYPE,
+ conditions=({"image_processing_mode": "image_data"},),
+ ),
+ CONF_IMAGE_ENCODING: PlatformField(
+ selector=IMAGE_ENCODING_SELECTOR,
required=False,
- validator=validate(cv.template),
- error="invalid_template",
+ conditions=({"image_processing_mode": "image_data"},),
+ default="raw",
),
- CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField(
- selector=TEMPLATE_SELECTOR,
- required=False,
- validator=validate(cv.template),
- error="invalid_template",
- conditions=({CONF_STATE_CLASS: "total"},),
- ),
- CONF_EXPIRE_AFTER: PlatformField(
- selector=TIMEOUT_SELECTOR,
- required=False,
- validator=cv.positive_int,
- section="advanced_settings",
- ),
- },
- Platform.SWITCH.value: {
- CONF_COMMAND_TOPIC: PlatformField(
+ CONF_URL_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
- validator=valid_publish_topic,
- error="invalid_publish_topic",
- ),
- CONF_COMMAND_TEMPLATE: PlatformField(
- selector=TEMPLATE_SELECTOR,
- required=False,
- validator=validate(cv.template),
- error="invalid_template",
- ),
- CONF_STATE_TOPIC: PlatformField(
- selector=TEXT_SELECTOR,
- required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
+ conditions=({"image_processing_mode": "image_url"},),
),
- CONF_VALUE_TEMPLATE: PlatformField(
+ CONF_URL_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
- CONF_PAYLOAD_OFF: PlatformField(
- selector=TEXT_SELECTOR,
- required=False,
- default=DEFAULT_PAYLOAD_OFF,
- ),
- CONF_PAYLOAD_ON: PlatformField(
- selector=TEXT_SELECTOR,
- required=False,
- default=DEFAULT_PAYLOAD_ON,
- ),
- CONF_STATE_OFF: PlatformField(
- selector=TEXT_SELECTOR,
- required=False,
- ),
- CONF_STATE_ON: PlatformField(
- selector=TEXT_SELECTOR,
- required=False,
- ),
- CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
- CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.LIGHT.value: {
CONF_COMMAND_TOPIC: PlatformField(
@@ -2664,22 +2865,182 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
section="advanced_settings",
),
},
+ Platform.LOCK.value: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
+ ),
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_CODE_FORMAT: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ validator=validate(cv.is_regex),
+ error="invalid_regular_expression",
+ ),
+ CONF_PAYLOAD_LOCK: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_LOCK,
+ section="lock_payload_settings",
+ ),
+ CONF_PAYLOAD_UNLOCK: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_UNLOCK,
+ section="lock_payload_settings",
+ ),
+ CONF_PAYLOAD_OPEN: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ section="lock_payload_settings",
+ ),
+ CONF_PAYLOAD_RESET: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_RESET,
+ section="lock_payload_settings",
+ ),
+ CONF_STATE_LOCKED: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_STATE_LOCKED,
+ section="lock_payload_settings",
+ ),
+ CONF_STATE_UNLOCKED: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_STATE_UNLOCKED,
+ section="lock_payload_settings",
+ ),
+ CONF_STATE_LOCKING: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_STATE_LOCKING,
+ section="lock_payload_settings",
+ ),
+ CONF_STATE_UNLOCKING: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_STATE_UNLOCKING,
+ section="lock_payload_settings",
+ ),
+ CONF_STATE_JAMMED: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_STATE_JAMMED,
+ section="lock_payload_settings",
+ ),
+ CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ },
+ Platform.NOTIFY.value: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
+ ),
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ },
+ Platform.SENSOR.value: {
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ conditions=({CONF_STATE_CLASS: "total"},),
+ ),
+ CONF_EXPIRE_AFTER: PlatformField(
+ selector=TIMEOUT_SELECTOR,
+ required=False,
+ validator=cv.positive_int,
+ section="advanced_settings",
+ ),
+ },
+ Platform.SWITCH.value: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
+ ),
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=validate(cv.template),
+ error="invalid_template",
+ ),
+ CONF_PAYLOAD_OFF: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_OFF,
+ ),
+ CONF_PAYLOAD_ON: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ default=DEFAULT_PAYLOAD_ON,
+ ),
+ CONF_STATE_OFF: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ ),
+ CONF_STATE_ON: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ ),
+ CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
+ },
}
-ENTITY_CONFIG_VALIDATOR: dict[
- str,
- Callable[[dict[str, Any]], dict[str, str]] | None,
-] = {
- Platform.BINARY_SENSOR.value: None,
- Platform.BUTTON.value: None,
- Platform.CLIMATE.value: validate_climate_platform_config,
- Platform.COVER.value: validate_cover_platform_config,
- Platform.FAN.value: validate_fan_platform_config,
- Platform.LIGHT.value: validate_light_platform_config,
- Platform.NOTIFY.value: None,
- Platform.SENSOR.value: validate_sensor_platform_config,
- Platform.SWITCH.value: None,
-}
-
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
ATTR_SW_VERSION: PlatformField(
@@ -2690,6 +3051,7 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
),
ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
+ ATTR_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_CONFIGURATION_URL: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
),
@@ -2702,54 +3064,6 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
),
}
-REAUTH_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_USERNAME): TEXT_SELECTOR,
- vol.Required(CONF_PASSWORD): PASSWORD_SELECTOR,
- }
-)
-PWD_NOT_CHANGED = "__**password_not_changed**__"
-
-
-@callback
-def update_password_from_user_input(
- entry_password: str | None, user_input: dict[str, Any]
-) -> dict[str, Any]:
- """Update the password if the entry has been updated.
-
- As we want to avoid reflecting the stored password in the UI,
- we replace the suggested value in the UI with a sentitel,
- and we change it back here if it was changed.
- """
- substituted_used_data = dict(user_input)
- # Take out the password submitted
- user_password: str | None = substituted_used_data.pop(CONF_PASSWORD, None)
- # Only add the password if it has changed.
- # If the sentinel password is submitted, we replace that with our current
- # password from the config entry data.
- password_changed = user_password is not None and user_password != PWD_NOT_CHANGED
- password = user_password if password_changed else entry_password
- if password is not None:
- substituted_used_data[CONF_PASSWORD] = password
- return substituted_used_data
-
-
-@callback
-def validate_field(
- field: str,
- validator: Callable[..., Any],
- user_input: dict[str, Any] | None,
- errors: dict[str, str],
- error: str,
-) -> None:
- """Validate a single field."""
- if user_input is None or field not in user_input or validator is None:
- return
- try:
- user_input[field] = validator(user_input[field])
- except (ValueError, vol.Error, vol.Invalid):
- errors[field] = error
-
@callback
def _check_conditions(
@@ -2783,49 +3097,6 @@ def calculate_merged_config(
} | merged_user_input
-@callback
-def validate_user_input(
- user_input: dict[str, Any],
- data_schema_fields: dict[str, PlatformField],
- *,
- component_data: dict[str, Any] | None = None,
- config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None,
-) -> tuple[dict[str, Any], dict[str, str]]:
- """Validate user input."""
- errors: dict[str, str] = {}
- # Merge sections
- merged_user_input: dict[str, Any] = {}
- for key, value in user_input.items():
- if isinstance(value, dict):
- merged_user_input.update(value)
- else:
- merged_user_input[key] = value
-
- for field, value in merged_user_input.items():
- validator = data_schema_fields[field].validator
- try:
- merged_user_input[field] = (
- validator(value) if validator is not None else value
- )
- except (ValueError, vol.Error, vol.Invalid):
- data_schema_field = data_schema_fields[field]
- errors[data_schema_field.section or field] = (
- data_schema_field.error or "invalid_input"
- )
-
- if config_validator is not None:
- if TYPE_CHECKING:
- assert component_data is not None
-
- errors |= config_validator(
- calculate_merged_config(
- merged_user_input, data_schema_fields, component_data
- ),
- )
-
- return merged_user_input, errors
-
-
@callback
def data_schema_from_fields(
data_schema_fields: dict[str, PlatformField],
@@ -2863,13 +3134,24 @@ def data_schema_from_fields(
data_schema: dict[Any, Any] = {}
all_data_element_options: set[Any] = set()
no_reconfig_options: set[Any] = set()
+
+ defaults: dict[str, Any] = {}
+ for field_name, field_details in data_schema_fields.items():
+ default = defaults[field_name] = get_default(field_details)
+ if not field_details.include_in_config or component_data is None:
+ continue
+ component_data[field_name] = default
+
for schema_section in sections:
+ # Always calculate the default values
+ # Getting the default value may update the subentry data,
+ # even when and option is filtered out
data_schema_element = {
- vol.Required(field_name, default=get_default(field_details))
+ vol.Required(field_name, default=defaults[field_name])
if field_details.required
else vol.Optional(
field_name,
- default=get_default(field_details)
+ default=defaults[field_name]
if field_details.default is not None
else vol.UNDEFINED,
): field_details.selector(component_data_with_user_input or {})
@@ -2918,16 +3200,63 @@ def data_schema_from_fields(
)
# Reset all fields from the component_data not in the schema
+ # except for options that should stay included
if component_data:
filtered_fields = (
set(data_schema_fields) - all_data_element_options - no_reconfig_options
)
for field in filtered_fields:
- if field in component_data:
+ if (
+ field in component_data
+ and not data_schema_fields[field].include_in_config
+ ):
del component_data[field]
return vol.Schema(data_schema)
+@callback
+def validate_user_input(
+ user_input: dict[str, Any],
+ data_schema_fields: dict[str, PlatformField],
+ *,
+ component_data: dict[str, Any] | None = None,
+ config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None,
+) -> tuple[dict[str, Any], dict[str, str]]:
+ """Validate user input."""
+ errors: dict[str, str] = {}
+ # Merge sections
+ merged_user_input: dict[str, Any] = {}
+ for key, value in user_input.items():
+ if isinstance(value, dict):
+ merged_user_input.update(value)
+ else:
+ merged_user_input[key] = value
+
+ for field, value in merged_user_input.items():
+ validator = data_schema_fields[field].validator
+ try:
+ merged_user_input[field] = (
+ validator(value) if validator is not None else value
+ )
+ except (ValueError, vol.Error, vol.Invalid):
+ data_schema_field = data_schema_fields[field]
+ errors[data_schema_field.section or field] = (
+ data_schema_field.error or "invalid_input"
+ )
+
+ if config_validator is not None:
+ if TYPE_CHECKING:
+ assert component_data is not None
+
+ errors |= config_validator(
+ calculate_merged_config(
+ merged_user_input, data_schema_fields, component_data
+ ),
+ )
+
+ return merged_user_input, errors
+
+
@callback
def subentry_schema_default_data_from_fields(
data_schema_fields: dict[str, PlatformField],
@@ -2945,6 +3274,37 @@ def subentry_schema_default_data_from_fields(
}
+@callback
+def update_password_from_user_input(
+ entry_password: str | None, user_input: dict[str, Any]
+) -> dict[str, Any]:
+ """Update the password if the entry has been updated.
+
+ As we want to avoid reflecting the stored password in the UI,
+ we replace the suggested value in the UI with a sentitel,
+ and we change it back here if it was changed.
+ """
+ substituted_used_data = dict(user_input)
+ # Take out the password submitted
+ user_password: str | None = substituted_used_data.pop(CONF_PASSWORD, None)
+ # Only add the password if it has changed.
+ # If the sentinel password is submitted, we replace that with our current
+ # password from the config entry data.
+ password_changed = user_password is not None and user_password != PWD_NOT_CHANGED
+ password = user_password if password_changed else entry_password
+ if password is not None:
+ substituted_used_data[CONF_PASSWORD] = password
+ return substituted_used_data
+
+
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): TEXT_SELECTOR,
+ vol.Required(CONF_PASSWORD): PASSWORD_SELECTOR,
+ }
+)
+
+
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -3485,6 +3845,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
for field, platform_field in data_schema_fields.items()
if field in (set(component_data) - set(config))
and not platform_field.exclude_from_reconfig
+ and not platform_field.include_in_config
):
component_data.pop(field)
component_data.update(merged_user_input)
@@ -3800,7 +4161,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
)
component_data.update(subentry_default_data)
for key, platform_field in platform_fields.items():
- if not platform_field.exclude_from_config:
+ if (
+ not platform_field.exclude_from_config
+ or platform_field.include_in_config
+ ):
continue
if key in component_data:
component_data.pop(key)
@@ -4391,7 +4755,7 @@ async def async_get_broker_settings( # noqa: C901
CONF_CLIENT_KEY,
description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)},
)
- ] = KEY_UPLOAD_SELECTOR
+ ] = CERT_KEY_UPLOAD_SELECTOR
fields[
vol.Optional(
CONF_CLIENT_KEY_PASSWORD,
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
index 1dfdb8dac53..d16617ef2a4 100644
--- a/homeassistant/components/mqtt/const.py
+++ b/homeassistant/components/mqtt/const.py
@@ -4,6 +4,7 @@ import logging
import jinja2
+from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
@@ -31,10 +32,18 @@ CONF_AVAILABILITY_TEMPLATE = "availability_template"
CONF_AVAILABILITY_TOPIC = "availability_topic"
CONF_BROKER = "broker"
CONF_BIRTH_MESSAGE = "birth_message"
+CONF_CODE_ARM_REQUIRED = "code_arm_required"
+CONF_CODE_DISARM_REQUIRED = "code_disarm_required"
+CONF_CODE_FORMAT = "code_format"
+CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required"
CONF_COMMAND_TEMPLATE = "command_template"
CONF_COMMAND_TOPIC = "command_topic"
+CONF_CONTENT_TYPE = "content_type"
+CONF_DEFAULT_ENTITY_ID = "default_entity_id"
CONF_DISCOVERY_PREFIX = "discovery_prefix"
CONF_ENCODING = "encoding"
+CONF_IMAGE_ENCODING = "image_encoding"
+CONF_IMAGE_TOPIC = "image_topic"
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
CONF_KEEPALIVE = "keepalive"
@@ -126,7 +135,14 @@ CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic"
CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template"
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template"
+CONF_PAYLOAD_ARM_AWAY = "payload_arm_away"
+CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass"
+CONF_PAYLOAD_ARM_HOME = "payload_arm_home"
+CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night"
+CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation"
CONF_PAYLOAD_CLOSE = "payload_close"
+CONF_PAYLOAD_DISARM = "payload_disarm"
+CONF_PAYLOAD_LOCK = "payload_lock"
CONF_PAYLOAD_OPEN = "payload_open"
CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off"
CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on"
@@ -135,6 +151,8 @@ CONF_PAYLOAD_RESET_PERCENTAGE = "payload_reset_percentage"
CONF_PAYLOAD_RESET_PRESET_MODE = "payload_reset_preset_mode"
CONF_PAYLOAD_STOP = "payload_stop"
CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt"
+CONF_PAYLOAD_TRIGGER = "payload_trigger"
+CONF_PAYLOAD_UNLOCK = "payload_unlock"
CONF_PERCENTAGE_COMMAND_TEMPLATE = "percentage_command_template"
CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic"
CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic"
@@ -168,11 +186,16 @@ CONF_SPEED_RANGE_MAX = "speed_range_max"
CONF_SPEED_RANGE_MIN = "speed_range_min"
CONF_STATE_CLOSED = "state_closed"
CONF_STATE_CLOSING = "state_closing"
+CONF_STATE_JAMMED = "state_jammed"
+CONF_STATE_LOCKED = "state_locked"
+CONF_STATE_LOCKING = "state_locking"
CONF_STATE_OFF = "state_off"
CONF_STATE_ON = "state_on"
CONF_STATE_OPEN = "state_open"
CONF_STATE_OPENING = "state_opening"
CONF_STATE_STOPPED = "state_stopped"
+CONF_STATE_UNLOCKED = "state_unlocked"
+CONF_STATE_UNLOCKING = "state_unlocking"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
@@ -211,6 +234,8 @@ CONF_TILT_MIN = "tilt_min"
CONF_TILT_OPEN_POSITION = "tilt_opened_value"
CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
CONF_TRANSITION = "transition"
+CONF_URL_TEMPLATE = "url_template"
+CONF_URL_TOPIC = "url_topic"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic"
@@ -239,6 +264,7 @@ CONF_CONFIGURATION_URL = "configuration_url"
CONF_OBJECT_ID = "object_id"
CONF_SUPPORT_URL = "support_url"
+DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE = "{{action}}"
DEFAULT_BRIGHTNESS = False
DEFAULT_BRIGHTNESS_SCALE = 255
DEFAULT_CLIMATE_INITIAL_TEMPERATURE = 21.0
@@ -252,8 +278,16 @@ DEFAULT_FLASH_TIME_SHORT = 2
DEFAULT_OPTIMISTIC = False
DEFAULT_ON_COMMAND_TYPE = "last"
DEFAULT_QOS = 0
+
+DEFAULT_PAYLOAD_ARM_AWAY = "ARM_AWAY"
+DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS"
+DEFAULT_PAYLOAD_ARM_HOME = "ARM_HOME"
+DEFAULT_PAYLOAD_ARM_NIGHT = "ARM_NIGHT"
+DEFAULT_PAYLOAD_ARM_VACATION = "ARM_VACATION"
DEFAULT_PAYLOAD_AVAILABLE = "online"
DEFAULT_PAYLOAD_CLOSE = "CLOSE"
+DEFAULT_PAYLOAD_DISARM = "DISARM"
+DEFAULT_PAYLOAD_LOCK = "LOCK"
DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline"
DEFAULT_PAYLOAD_OFF = "OFF"
DEFAULT_PAYLOAD_ON = "ON"
@@ -261,8 +295,10 @@ DEFAULT_PAYLOAD_OPEN = "OPEN"
DEFAULT_PAYLOAD_OSCILLATE_OFF = "oscillate_off"
DEFAULT_PAYLOAD_OSCILLATE_ON = "oscillate_on"
DEFAULT_PAYLOAD_PRESS = "PRESS"
-DEFAULT_PAYLOAD_STOP = "STOP"
DEFAULT_PAYLOAD_RESET = "None"
+DEFAULT_PAYLOAD_STOP = "STOP"
+DEFAULT_PAYLOAD_TRIGGER = "TRIGGER"
+DEFAULT_PAYLOAD_UNLOCK = "UNLOCK"
DEFAULT_PORT = 1883
DEFAULT_RETAIN = False
DEFAULT_TILT_CLOSED_POSITION = 0
@@ -277,7 +313,14 @@ DEFAULT_POSITION_OPEN = 100
DEFAULT_RETAIN = False
DEFAULT_SPEED_RANGE_MAX = 100
DEFAULT_SPEED_RANGE_MIN = 1
+DEFAULT_STATE_LOCKED = "LOCKED"
+DEFAULT_STATE_LOCKING = "LOCKING"
+DEFAULT_STATE_OPEN = "OPEN"
+DEFAULT_STATE_OPENING = "OPENING"
DEFAULT_STATE_STOPPED = "stopped"
+DEFAULT_STATE_UNLOCKED = "UNLOCKED"
+DEFAULT_STATE_UNLOCKING = "UNLOCKING"
+DEFAULT_STATE_JAMMED = "JAMMED"
DEFAULT_WHITE_SCALE = 255
COVER_PAYLOAD = "cover"
@@ -285,6 +328,17 @@ TILT_PAYLOAD = "tilt"
VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"]
+ALARM_CONTROL_PANEL_SUPPORTED_FEATURES = {
+ "arm_home": AlarmControlPanelEntityFeature.ARM_HOME,
+ "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY,
+ "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT,
+ "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION,
+ "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
+ "trigger": AlarmControlPanelEntityFeature.TRIGGER,
+}
+REMOTE_CODE = "REMOTE_CODE"
+REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT"
+
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
PROTOCOL_5 = "5"
diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py
index 366f2f13ad4..2738332bb15 100644
--- a/homeassistant/components/mqtt/device_automation.py
+++ b/homeassistant/components/mqtt/device_automation.py
@@ -25,7 +25,9 @@ DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend(
)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def async_setup_mqtt_device_automation_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> None:
"""Set up MQTT device automation dynamically through MQTT discovery."""
setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry)
diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py
index f0e7f915551..3f7e4f030ab 100644
--- a/homeassistant/components/mqtt/entity.py
+++ b/homeassistant/components/mqtt/entity.py
@@ -29,6 +29,7 @@ from homeassistant.const import (
CONF_MODEL_ID,
CONF_NAME,
CONF_UNIQUE_ID,
+ CONF_URL,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import Event, HassJobType, HomeAssistant, callback
@@ -74,6 +75,7 @@ from .const import (
CONF_AVAILABILITY_TOPIC,
CONF_CONFIGURATION_URL,
CONF_CONNECTIONS,
+ CONF_DEFAULT_ENTITY_ID,
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
@@ -83,6 +85,7 @@ from .const import (
CONF_JSON_ATTRS_TOPIC,
CONF_MANUFACTURER,
CONF_OBJECT_ID,
+ CONF_ORIGIN,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_QOS,
@@ -1406,12 +1409,62 @@ class MqttEntity(
ensure_via_device_exists(self.hass, self.device_info, self._config_entry)
def _init_entity_id(self) -> None:
- """Set entity_id from object_id if defined in config."""
- if CONF_OBJECT_ID not in self._config:
+ """Set entity_id from default_entity_id if defined in config."""
+ object_id: str
+ default_entity_id: str | None
+ # Setting the default entity_id through the CONF_OBJECT_ID is deprecated
+ # Support will be removed with HA Core 2026.4
+ if (
+ CONF_DEFAULT_ENTITY_ID not in self._config
+ and CONF_OBJECT_ID not in self._config
+ ):
return
+ if (default_entity_id := self._config.get(CONF_DEFAULT_ENTITY_ID)) is None:
+ object_id = self._config[CONF_OBJECT_ID]
+ else:
+ _, _, object_id = default_entity_id.partition(".")
self.entity_id = async_generate_entity_id(
- self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass
+ self._entity_id_format, object_id, None, self.hass
)
+ if CONF_OBJECT_ID in self._config:
+ domain = self.entity_id.split(".")[0]
+ if not self._discovery:
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ self.entity_id,
+ issue_domain=DOMAIN,
+ is_fixable=False,
+ breaks_in_ha_version="2026.4",
+ severity=IssueSeverity.WARNING,
+ learn_more_url=f"{learn_more_url(domain)}#default_enity_id",
+ translation_placeholders={
+ "entity_id": self.entity_id,
+ "object_id": self._config[CONF_OBJECT_ID],
+ "domain": domain,
+ },
+ translation_key="deprecated_object_id",
+ )
+ elif CONF_DEFAULT_ENTITY_ID not in self._config:
+ if CONF_ORIGIN in self._config:
+ origin_name = self._config[CONF_ORIGIN][CONF_NAME]
+ url = self._config[CONF_ORIGIN].get(CONF_URL)
+ origin = f"[{origin_name}]({url})" if url else origin_name
+ else:
+ origin = "the integration"
+ _LOGGER.warning(
+ "The configuration for entity %s uses the deprecated option "
+ "`object_id` to set the default entity id. Replace the "
+ '`"object_id": "%s"` option with `"default_entity_id": '
+ '"%s"` in your published discovery configuration to fix this '
+ "issue, or contact the maintainer of %s that published this config "
+ "to fix this. This will stop working in Home Assistant Core 2026.4",
+ self.entity_id,
+ self._config[CONF_OBJECT_ID],
+ f"{domain}.{self._config[CONF_OBJECT_ID]}",
+ origin,
+ )
+
if self.unique_id is None:
return
# Check for previous deleted entities
diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py
index a668608dd55..5e84e83bf69 100644
--- a/homeassistant/components/mqtt/image.py
+++ b/homeassistant/components/mqtt/image.py
@@ -25,6 +25,13 @@ from homeassistant.util import dt as dt_util
from . import subscription
from .config import MQTT_BASE_SCHEMA
+from .const import (
+ CONF_CONTENT_TYPE,
+ CONF_IMAGE_ENCODING,
+ CONF_IMAGE_TOPIC,
+ CONF_URL_TEMPLATE,
+ CONF_URL_TOPIC,
+)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import (
DATA_MQTT,
@@ -39,12 +46,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-CONF_CONTENT_TYPE = "content_type"
-CONF_IMAGE_ENCODING = "image_encoding"
-CONF_IMAGE_TOPIC = "image_topic"
-CONF_URL_TEMPLATE = "url_template"
-CONF_URL_TOPIC = "url_topic"
-
DEFAULT_NAME = "MQTT Image"
GET_IMAGE_TIMEOUT = 10
@@ -67,7 +68,7 @@ PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic,
vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic,
- vol.Optional(CONF_IMAGE_ENCODING): "b64",
+ vol.Optional(CONF_IMAGE_ENCODING): vol.In({"b64", "raw"}),
vol.Optional(CONF_URL_TEMPLATE): cv.template,
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
@@ -146,7 +147,7 @@ class MqttImage(MqttEntity, ImageEntity):
def _image_data_received(self, msg: ReceiveMessage) -> None:
"""Handle new MQTT messages."""
try:
- if CONF_IMAGE_ENCODING in self._config:
+ if self._config.get(CONF_IMAGE_ENCODING) == "b64":
self._last_image = b64decode(msg.payload)
else:
if TYPE_CHECKING:
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index fc76d4bcf6c..debc558dec5 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -91,8 +91,6 @@ from .schema_basic import (
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "mqtt_json"
-
DEFAULT_NAME = "MQTT JSON Light"
DEFAULT_FLASH = True
@@ -223,6 +221,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
# Brightness is supported and no supported_color_modes are set,
# so set brightness as the supported color mode.
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ else:
+ self._attr_supported_color_modes = {ColorMode.ONOFF}
def _update_color(self, values: dict[str, Any]) -> None:
color_mode: str = values["color_mode"]
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index 727e689798e..2232abb7934 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -27,12 +27,31 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import subscription
from .config import MQTT_RW_SCHEMA
from .const import (
+ CONF_CODE_FORMAT,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
+ CONF_PAYLOAD_LOCK,
+ CONF_PAYLOAD_OPEN,
CONF_PAYLOAD_RESET,
+ CONF_PAYLOAD_UNLOCK,
+ CONF_STATE_JAMMED,
+ CONF_STATE_LOCKED,
+ CONF_STATE_LOCKING,
CONF_STATE_OPEN,
CONF_STATE_OPENING,
CONF_STATE_TOPIC,
+ CONF_STATE_UNLOCKED,
+ CONF_STATE_UNLOCKING,
+ DEFAULT_PAYLOAD_LOCK,
+ DEFAULT_PAYLOAD_RESET,
+ DEFAULT_PAYLOAD_UNLOCK,
+ DEFAULT_STATE_JAMMED,
+ DEFAULT_STATE_LOCKED,
+ DEFAULT_STATE_LOCKING,
+ DEFAULT_STATE_OPEN,
+ DEFAULT_STATE_OPENING,
+ DEFAULT_STATE_UNLOCKED,
+ DEFAULT_STATE_UNLOCKING,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import (
@@ -47,31 +66,7 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-CONF_CODE_FORMAT = "code_format"
-
-CONF_PAYLOAD_LOCK = "payload_lock"
-CONF_PAYLOAD_UNLOCK = "payload_unlock"
-CONF_PAYLOAD_OPEN = "payload_open"
-
-CONF_STATE_LOCKED = "state_locked"
-CONF_STATE_LOCKING = "state_locking"
-
-CONF_STATE_UNLOCKED = "state_unlocked"
-CONF_STATE_UNLOCKING = "state_unlocking"
-CONF_STATE_JAMMED = "state_jammed"
-
DEFAULT_NAME = "MQTT Lock"
-DEFAULT_PAYLOAD_LOCK = "LOCK"
-DEFAULT_PAYLOAD_UNLOCK = "UNLOCK"
-DEFAULT_PAYLOAD_OPEN = "OPEN"
-DEFAULT_PAYLOAD_RESET = "None"
-DEFAULT_STATE_LOCKED = "LOCKED"
-DEFAULT_STATE_LOCKING = "LOCKING"
-DEFAULT_STATE_OPEN = "OPEN"
-DEFAULT_STATE_OPENING = "OPENING"
-DEFAULT_STATE_UNLOCKED = "UNLOCKED"
-DEFAULT_STATE_UNLOCKING = "UNLOCKING"
-DEFAULT_STATE_JAMMED = "JAMMED"
MQTT_LOCK_ATTRIBUTES_BLOCKED = frozenset(
{
@@ -193,7 +188,10 @@ class MqttLock(MqttEntity, LockEntity):
return
if payload == self._config[CONF_PAYLOAD_RESET]:
# Reset the state to `unknown`
- self._attr_is_locked = None
+ self._attr_is_locked = self._attr_is_locking = None
+ self._attr_is_unlocking = None
+ self._attr_is_open = self._attr_is_opening = None
+ self._attr_is_jammed = None
elif payload in self._valid_states:
self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED]
self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING]
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
index 1cd6ae3e47c..754d07c10fe 100644
--- a/homeassistant/components/mqtt/manifest.json
+++ b/homeassistant/components/mqtt/manifest.json
@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["file_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/mqtt",
+ "integration_type": "service",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["paho-mqtt==2.1.0"],
diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py
index 5e942c24738..0a9609dfc6d 100644
--- a/homeassistant/components/mqtt/schemas.py
+++ b/homeassistant/components/mqtt/schemas.py
@@ -32,6 +32,7 @@ from .const import (
CONF_COMPONENTS,
CONF_CONFIGURATION_URL,
CONF_CONNECTIONS,
+ CONF_DEFAULT_ENTITY_ID,
CONF_DEPRECATED_VIA_HUB,
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
@@ -180,6 +181,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
+ vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_OBJECT_ID): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 77a476bf40c..49449c2f52d 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -1,5 +1,9 @@
{
"issues": {
+ "deprecated_object_id": {
+ "title": "Deprecated option object_id used",
+ "description": "Entity {entity_id} uses the `object_id` option which is deprecated. To fix the issue, replace the `object_id: {object_id}` option with `default_entity_id: {domain}.{object_id}` in your \"configuration.yaml\", and restart Home Assistant."
+ },
"deprecated_vacuum_battery_feature": {
"title": "Deprecated battery feature used",
"description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant."
@@ -161,13 +165,15 @@
"name": "[%key:common::config_flow::data::name%]",
"configuration_url": "Configuration URL",
"model": "Model",
- "model_id": "Model ID"
+ "model_id": "Model ID",
+ "manufacturer": "Manufacturer"
},
"data_description": {
"name": "The name of the manually added MQTT device.",
"configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.",
"model": "E.g. 'Cleanmaster Pro'.",
- "model_id": "E.g. '123NK2PRO'."
+ "model_id": "E.g. '123NK2PRO'.",
+ "manufacturer": "E.g. Cleanmaster Ltd."
},
"sections": {
"advanced_settings": {
@@ -243,6 +249,7 @@
"title": "Configure MQTT device \"{mqtt_device}\"",
"description": "Please configure specific details for {platform} entity \"{entity}\":",
"data": {
+ "alarm_control_panel_code_mode": "Alarm code validation mode",
"climate_feature_action": "Current action support",
"climate_feature_current_humidity": "Current humidity support",
"climate_feature_current_temperature": "Current temperature support",
@@ -259,14 +266,17 @@
"fan_feature_preset_modes": "Preset modes support",
"fan_feature_oscillation": "Oscillation support",
"fan_feature_direction": "Direction support",
+ "image_processing_mode": "Image processing mode",
"options": "Add option",
"schema": "Schema",
"state_class": "State class",
"suggested_display_precision": "Suggested display precision",
+ "supported_features": "Supported features",
"temperature_unit": "Temperature unit",
"unit_of_measurement": "Unit of measurement"
},
"data_description": {
+ "alarm_control_panel_code_mode": "Configures how the alarm control panel validates the code. A local code is configured with the entity and is validated by Home Assistant. A remote code is sent to the device and validated remotely. [Learn more.]({url}#code)",
"climate_feature_action": "The climate supports reporting the current action.",
"climate_feature_current_humidity": "The climate supports reporting the current humidity.",
"climate_feature_current_temperature": "The climate supports reporting the current temperature.",
@@ -283,10 +293,12 @@
"fan_feature_preset_modes": "The fan supports preset modes.",
"fan_feature_oscillation": "The fan supports oscillation.",
"fan_feature_direction": "The fan supports direction.",
+ "image_processing_mode": "Select how the image data is received.",
"options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.",
"schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)",
"state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
+ "supported_features": "The features that the entity supports.",
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any."
},
@@ -308,13 +320,21 @@
"data": {
"blue_template": "Blue template",
"brightness_template": "Brightness template",
+ "code": "Alarm code",
+ "code_format": "Code format",
+ "code_arm_required": "Code arm required",
+ "code_disarm_required": "Code disarm required",
+ "code_trigger_required": "Code trigger required",
+ "color_temp_template": "Color temperature template",
"command_template": "Command template",
"command_topic": "Command topic",
"command_off_template": "Command \"off\" template",
"command_on_template": "Command \"on\" template",
- "color_temp_template": "Color temperature template",
+ "content_type": "Content type",
"force_update": "Force update",
"green_template": "Green template",
+ "image_encoding": "Image encoding",
+ "image_topic": "Image topic",
"last_reset_value_template": "Last reset value template",
"modes": "Supported operation modes",
"mode_command_topic": "Operation mode command topic",
@@ -335,18 +355,28 @@
"state_topic": "State topic",
"state_value_template": "State value template",
"supported_color_modes": "Supported color modes",
+ "url_template": "URL template",
+ "url_topic": "URL topic",
"value_template": "Value template"
},
"data_description": {
"blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.",
"brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.",
+ "code": "Specifies a code to enable or disable the alarm in the frontend. Note that this blocks sending MQTT message commands to the remote device if the code validation fails. [Learn more.]({url}#code)",
+ "code_format": "A regular expression to validate a supplied code when it is set during the action to open, lock or unlock the MQTT lock. [Learn more.]({url}#code_format)",
+ "code_arm_required": "If set, the code is required to arm the alarm. If not set, the code is not validated.",
+ "code_disarm_required": "If set, the code is required to disarm the alarm. If not set, the code is not validated.",
+ "code_trigger_required": "If set, the code is required to manually trigger the alarm. If not set, the code is not validated.",
+ "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.",
"command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.",
"command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.",
- "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.",
+ "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)",
"command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)",
- "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.",
+ "content_type": "The content type or the image data that is received at the image topic.",
"force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)",
"green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.",
+ "image_encoding": "Select the encoding of the received image data",
+ "image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)",
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
@@ -366,6 +396,8 @@
"state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.",
"state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
+ "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
+ "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
"value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)"
},
"sections": {
@@ -392,6 +424,27 @@
"transition": "Enable the transition feature for this light"
}
},
+ "alarm_control_panel_payload_settings": {
+ "name": "Alarm control panel payload settings",
+ "data": {
+ "payload_arm_away": "Payload \"arm away\"",
+ "payload_arm_custom_bypass": "Payload \"arm custom bypass\"",
+ "payload_arm_disarm": "Payload \"disarm\"",
+ "payload_arm_home": "Payload \"arm home\"",
+ "payload_arm_night": "Payload \"arm night\"",
+ "payload_arm_vacation": "Payload \"arm vacation\"",
+ "payload_trigger": "Payload \"trigger alarm\""
+ },
+ "data_description": {
+ "payload_arm_away": "The payload sent when an \"arm away\" command is issued.",
+ "payload_arm_custom_bypass": "The payload sent when an \"arm custom bypass\" command is issued.",
+ "payload_arm_disarm": "The payload sent when a \"disarm\" command is issued.",
+ "payload_arm_home": "The payload sent when an \"arm home\" command is issued.",
+ "payload_arm_night": "The payload sent when an \"arm night\" command is issued.",
+ "payload_arm_vacation": "The payload sent when an \"arm vacation\" command is issued.",
+ "payload_trigger": "The payload sent when a \"trigger alarm\" command is issued."
+ }
+ },
"climate_action_settings": {
"name": "Current action settings",
"data": {
@@ -596,6 +649,31 @@
"brightness_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the brightness value."
}
},
+ "lock_payload_settings": {
+ "name": "Lock payload settings",
+ "data": {
+ "payload_lock": "Payload \"lock\"",
+ "payload_open": "Payload \"open\"",
+ "payload_reset": "Payload \"reset\"",
+ "payload_unlock": "Payload \"unlock\"",
+ "state_jammed": "State \"jammed\"",
+ "state_locked": "State \"locked\"",
+ "state_locking": "State \"locking\"",
+ "state_unlocked": "State \"unlocked\"",
+ "state_unlocking": "State \"unlocking\""
+ },
+ "data_description": {
+ "payload_lock": "The payload sent when a \"lock\" command is issued.",
+ "payload_open": "The payload sent when an \"open\" command is issued. Set this payload if your lock supports the \"open\" action.",
+ "payload_reset": "The payload received at the state topic that resets the lock to an unknown state.",
+ "payload_unlock": "The payload sent when an \"unlock\" command is issued.",
+ "state_jammed": "The payload received at the state topic that represents the \"jammed\" state.",
+ "state_locked": "The payload received at the state topic that represents the \"locked\" state.",
+ "state_locking": "The payload received at the state topic that represents the \"locking\" state.",
+ "state_unlocked": "The payload received at the state topic that represents the \"unlocked\" state.",
+ "state_unlocking": "The payload received at the state topic that represents the \"unlocking\" state."
+ }
+ },
"fan_direction_settings": {
"name": "Direction settings",
"data": {
@@ -605,7 +683,7 @@
"direction_value_template": "Direction value template"
},
"data_description": {
- "direction_command_topic": "The MQTT topic to publish commands to change the fan direction payload, either `forward` or `reverse`. Use the direction command template to customize the payload. [Learn more.]({url}#direction_command_topic)",
+ "direction_command_topic": "The MQTT topic to publish commands to change the fan direction. The payload will be either `forward` or `reverse` and can be customized using the direction command template. [Learn more.]({url}#direction_command_topic)",
"direction_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the direction command topic. The template variable `value` will be either `forward` or `reverse`.",
"direction_state_topic": "The MQTT topic subscribed to receive fan direction state. Accepted state payloads are `forward` or `reverse`. [Learn more.]({url}#direction_state_topic)",
"direction_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan direction state value. The template should return either `forward` or `reverse`. When the template returns an empty string, the direction will be ignored."
@@ -911,6 +989,7 @@
"fan_speed_range_max_must_be_greater_than_speed_range_min": "Speed range max must be greater than speed range min",
"fan_preset_mode_reset_in_preset_modes_list": "Payload \"reset preset mode\" is not a valid as a preset mode",
"invalid_input": "Invalid value",
+ "invalid_regular_expression": "Must be a valid regular expression",
"invalid_subscribe_topic": "Invalid subscribe topic",
"invalid_template": "Invalid template",
"invalid_supported_color_modes": "Invalid supported color modes selection",
@@ -1042,6 +1121,23 @@
}
},
"selector": {
+ "alarm_control_panel_code_mode": {
+ "options": {
+ "local_code": "Local code validation",
+ "remote_code": "Numeric remote code validation",
+ "remote_code_text": "Text remote code validation"
+ }
+ },
+ "alarm_control_panel_features": {
+ "options": {
+ "arm_away": "[%key:component::alarm_control_panel::services::alarm_arm_away::name%]",
+ "arm_custom_bypass": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::name%]",
+ "arm_home": "[%key:component::alarm_control_panel::services::alarm_arm_home::name%]",
+ "arm_night": "[%key:component::alarm_control_panel::services::alarm_arm_night::name%]",
+ "arm_vacation": "[%key:component::alarm_control_panel::services::alarm_arm_vacation::name%]",
+ "trigger": "[%key:component::alarm_control_panel::services::alarm_trigger::name%]"
+ }
+ },
"climate_modes": {
"options": {
"off": "[%key:common::state::off%]",
@@ -1141,6 +1237,7 @@
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
@@ -1148,6 +1245,7 @@
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
+ "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
@@ -1179,6 +1277,18 @@
"diagnostic": "Diagnostic"
}
},
+ "image_encoding": {
+ "options": {
+ "raw": "Raw data",
+ "b64": "Base64 encoding"
+ }
+ },
+ "image_processing_mode": {
+ "options": {
+ "image_data": "Image data is received",
+ "image_url": "Image URL is received"
+ }
+ },
"light_schema": {
"options": {
"basic": "Default schema",
@@ -1195,12 +1305,15 @@
},
"platform": {
"options": {
+ "alarm_control_panel": "[%key:component::alarm_control_panel::title%]",
"binary_sensor": "[%key:component::binary_sensor::title%]",
"button": "[%key:component::button::title%]",
"climate": "[%key:component::climate::title%]",
"cover": "[%key:component::cover::title%]",
"fan": "[%key:component::fan::title%]",
+ "image": "[%key:component::image::title%]",
"light": "[%key:component::light::title%]",
+ "lock": "[%key:component::lock::title%]",
"notify": "[%key:component::notify::title%]",
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]"
diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py
index 9a05d1896f7..0615e0e7e6c 100644
--- a/homeassistant/components/mqtt/tag.py
+++ b/homeassistant/components/mqtt/tag.py
@@ -53,7 +53,9 @@ DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend(
)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+async def async_setup_mqtt_tag_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> None:
"""Set up MQTT tag scanner dynamically through MQTT discovery."""
setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry)
diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py
index 1bf743d3da7..3aea554e460 100644
--- a/homeassistant/components/mqtt/util.py
+++ b/homeassistant/components/mqtt/util.py
@@ -166,13 +166,19 @@ async def async_forward_entry_setup_and_setup_discovery(
from . import device_automation # noqa: PLC0415
tasks.append(
- create_eager_task(device_automation.async_setup_entry(hass, config_entry))
+ create_eager_task(
+ device_automation.async_setup_mqtt_device_automation_entry(
+ hass, config_entry
+ )
+ )
)
if "tag" in new_platforms:
# Local import to avoid circular dependencies
from . import tag # noqa: PLC0415
- tasks.append(create_eager_task(tag.async_setup_entry(hass, config_entry)))
+ tasks.append(
+ create_eager_task(tag.async_setup_mqtt_tag_entry(hass, config_entry))
+ )
if new_entity_platforms := (new_platforms - {"tag", "device_automation"}):
tasks.append(
create_eager_task(
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index 32024c5ad13..993d1023996 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -9,8 +9,10 @@ from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
+from music_assistant_models.config_entries import PlayerConfig
from music_assistant_models.enums import EventType
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
+from music_assistant_models.player import Player
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
@@ -25,7 +27,7 @@ from homeassistant.helpers.issue_registry import (
)
from .actions import get_music_assistant_client, register_actions
-from .const import DOMAIN, LOGGER
+from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
@@ -59,7 +61,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(
+async def async_setup_entry( # noqa: C901
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
"""Set up Music Assistant from a config entry."""
@@ -126,8 +128,25 @@ async def async_setup_entry(
# initialize platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ def add_player(player: Player) -> None:
+ """Handle adding Player from MA as HA device + entities."""
+ entry.runtime_data.discovered_players.add(player.player_id)
+ # run callback for each platform
+ for callback in entry.runtime_data.platform_handlers.values():
+ callback(player.player_id)
+
+ def remove_player(player_id: str) -> None:
+ """Handle removing Player from MA as HA device + entities."""
+ if player_id in entry.runtime_data.discovered_players:
+ entry.runtime_data.discovered_players.remove(player_id)
+ dev_reg = dr.async_get(hass)
+ if hass_device := dev_reg.async_get_device({(DOMAIN, player_id)}):
+ dev_reg.async_update_device(
+ hass_device.id, remove_config_entry_id=entry.entry_id
+ )
+
# register listener for new players
- async def handle_player_added(event: MassEvent) -> None:
+ def handle_player_added(event: MassEvent) -> None:
"""Handle Mass Player Added event."""
if TYPE_CHECKING:
assert event.object_id is not None
@@ -138,10 +157,7 @@ async def async_setup_entry(
assert player is not None
if not player.expose_to_ha:
return
- entry.runtime_data.discovered_players.add(event.object_id)
- # run callback for each platform
- for callback in entry.runtime_data.platform_handlers.values():
- callback(event.object_id)
+ add_player(player)
entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED))
@@ -149,25 +165,40 @@ async def async_setup_entry(
for player in mass.players:
if not player.expose_to_ha:
continue
- entry.runtime_data.discovered_players.add(player.player_id)
- for callback in entry.runtime_data.platform_handlers.values():
- callback(player.player_id)
+ add_player(player)
# register listener for removed players
- async def handle_player_removed(event: MassEvent) -> None:
+ def handle_player_removed(event: MassEvent) -> None:
"""Handle Mass Player Removed event."""
if event.object_id is None:
return
- dev_reg = dr.async_get(hass)
- if hass_device := dev_reg.async_get_device({(DOMAIN, event.object_id)}):
- dev_reg.async_update_device(
- hass_device.id, remove_config_entry_id=entry.entry_id
- )
+ remove_player(event.object_id)
entry.async_on_unload(
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
)
+ # register listener for player configs (to handle toggling of the 'expose_to_ha' setting)
+ def handle_player_config_updated(event: MassEvent) -> None:
+ """Handle Mass Player Config Updated event."""
+ if event.object_id is None or not event.data:
+ return
+ player_id = event.object_id
+ player_config = PlayerConfig.from_dict(event.data)
+ expose_to_ha = player_config.get_value(ATTR_CONF_EXPOSE_PLAYER_TO_HA, True)
+ if not expose_to_ha and player_id in entry.runtime_data.discovered_players:
+ # player is no longer exposed to Home Assistant
+ remove_player(player_id)
+ elif expose_to_ha and player_id not in entry.runtime_data.discovered_players:
+ # player is now exposed to Home Assistant
+ if not (player := mass.players.get(player_id)):
+ return # guard
+ add_player(player)
+
+ entry.async_on_unload(
+ mass.subscribe(handle_player_config_updated, EventType.PLAYER_CONFIG_UPDATED)
+ )
+
# check if any playerconfigs have been removed while we were disconnected
all_player_configs = await mass.config.get_player_configs()
player_ids = {player.player_id for player in all_player_configs}
diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py
index b00924c97a5..3426a08852a 100644
--- a/homeassistant/components/music_assistant/config_flow.py
+++ b/homeassistant/components/music_assistant/config_flow.py
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
from music_assistant_models.api import ServerInfoMessage
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@@ -68,7 +68,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
self.server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
- updates={CONF_URL: self.server_info.base_url},
+ updates={CONF_URL: user_input[CONF_URL]},
reload_on_update=True,
)
except CannotConnect:
@@ -82,7 +82,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=DEFAULT_TITLE,
data={
- CONF_URL: self.server_info.base_url,
+ CONF_URL: user_input[CONF_URL],
},
)
@@ -103,14 +103,40 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
# abort if discovery info is not what we expect
if "server_id" not in discovery_info.properties:
return self.async_abort(reason="missing_server_id")
- # abort if we already have exactly this server_id
- # reload the integration if the host got updated
+
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
await self.async_set_unique_id(self.server_info.server_id)
- self._abort_if_unique_id_configured(
- updates={CONF_URL: self.server_info.base_url},
- reload_on_update=True,
+
+ # Check if we already have a config entry for this server_id
+ existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
+ DOMAIN, self.server_info.server_id
)
+
+ if existing_entry:
+ # If the entry was ignored or disabled, don't make any changes
+ if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
+ return self.async_abort(reason="already_configured")
+
+ # Test connectivity to the current URL first
+ current_url = existing_entry.data[CONF_URL]
+ try:
+ await get_server_info(self.hass, current_url)
+ # Current URL is working, no need to update
+ return self.async_abort(reason="already_configured")
+ except CannotConnect:
+ # Current URL is not working, update to the discovered URL
+ # and continue to discovery confirm
+ self.hass.config_entries.async_update_entry(
+ existing_entry,
+ data={**existing_entry.data, CONF_URL: self.server_info.base_url},
+ )
+ # Schedule reload since URL changed
+ self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
+ else:
+ # No existing entry, proceed with normal flow
+ self._abort_if_unique_id_configured()
+
+ # Test connectivity to the discovered URL
try:
await get_server_info(self.hass, self.server_info.base_url)
except CannotConnect:
diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py
index 8c1701b4afd..d1a97382193 100644
--- a/homeassistant/components/music_assistant/const.py
+++ b/homeassistant/components/music_assistant/const.py
@@ -65,5 +65,6 @@ ATTR_STREAM_TITLE = "stream_title"
ATTR_PROVIDER = "provider"
ATTR_ITEM_ID = "item_id"
+ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py
index e4724be650a..fe50afe98e7 100644
--- a/homeassistant/components/music_assistant/media_browser.py
+++ b/homeassistant/components/music_assistant/media_browser.py
@@ -70,7 +70,7 @@ LIBRARY_MEDIA_CLASS_MAP = {
MEDIA_CONTENT_TYPE_FLAC = "audio/flac"
THUMB_SIZE = 200
-SORT_NAME_DESC = "sort_name_desc"
+SORT_NAME = "sort_name"
LOGGER = logging.getLogger(__name__)
@@ -143,7 +143,7 @@ async def build_main_listing(hass: HomeAssistant) -> BrowseMedia:
children.extend(item.children)
else:
children.append(item)
- except media_source.BrowseError:
+ except BrowseError:
pass
return BrowseMedia(
@@ -173,7 +173,7 @@ async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for item in await mass.music.get_library_playlists(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if item.available
],
@@ -225,7 +225,7 @@ async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for artist in await mass.music.get_library_artists(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if artist.available
],
@@ -275,7 +275,7 @@ async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for album in await mass.music.get_library_albums(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if album.available
],
@@ -323,7 +323,7 @@ async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for track in await mass.music.get_library_tracks(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if track.available
],
@@ -346,7 +346,7 @@ async def build_podcasts_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for podcast in await mass.music.get_library_podcasts(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if podcast.available
],
@@ -369,7 +369,7 @@ async def build_audiobooks_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for audiobook in await mass.music.get_library_audiobooks(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if audiobook.available
],
@@ -392,7 +392,7 @@ async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia:
# we only grab the first page here because the
# HA media browser does not support paging
for track in await mass.music.get_library_radios(
- limit=500, order_by=SORT_NAME_DESC
+ limit=500, order_by=SORT_NAME
)
if track.available
],
diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json
index 2c4e6a7e735..8058c602dc4 100644
--- a/homeassistant/components/mvglive/manifest.json
+++ b/homeassistant/components/mvglive/manifest.json
@@ -2,10 +2,8 @@
"domain": "mvglive",
"name": "MVG",
"codeowners": [],
- "disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
- "loggers": ["MVGLive"],
- "quality_scale": "legacy",
- "requirements": ["PyMVGLive==1.1.4"]
+ "loggers": ["MVG"],
+ "requirements": ["mvg==1.4.0"]
}
diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py
index d8b43517711..031ec164ecd 100644
--- a/homeassistant/components/mvglive/sensor.py
+++ b/homeassistant/components/mvglive/sensor.py
@@ -1,13 +1,14 @@
"""Support for departure information for public transport in Munich."""
-# mypy: ignore-errors
from __future__ import annotations
+from collections.abc import Mapping
from copy import deepcopy
from datetime import timedelta
import logging
+from typing import Any
-import MVGLive
+from mvg import MvgApi, MvgApiError, TransportType
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -44,53 +46,55 @@ ICONS = {
"SEV": "mdi:checkbox-blank-circle-outline",
"-": "mdi:clock",
}
-ATTRIBUTION = "Data provided by MVG-live.de"
+
+ATTRIBUTION = "Data provided by mvg.de"
SCAN_INTERVAL = timedelta(seconds=30)
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_NEXT_DEPARTURE): [
- {
- vol.Required(CONF_STATION): cv.string,
- vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
- vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
- vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
- vol.Optional(
- CONF_PRODUCTS, default=DEFAULT_PRODUCT
- ): cv.ensure_list_csv,
- vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
- vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
- vol.Optional(CONF_NAME): cv.string,
- }
- ]
- }
+PLATFORM_SCHEMA = vol.All(
+ cv.deprecated(CONF_DIRECTIONS),
+ SENSOR_PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_NEXT_DEPARTURE): [
+ {
+ vol.Required(CONF_STATION): cv.string,
+ vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
+ vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
+ vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
+ vol.Optional(
+ CONF_PRODUCTS, default=DEFAULT_PRODUCT
+ ): cv.ensure_list_csv,
+ vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
+ vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+ }
+ ]
+ }
+ ),
)
-def setup_platform(
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the MVGLive sensor."""
- add_entities(
- (
- MVGLiveSensor(
- nextdeparture.get(CONF_STATION),
- nextdeparture.get(CONF_DESTINATIONS),
- nextdeparture.get(CONF_DIRECTIONS),
- nextdeparture.get(CONF_LINES),
- nextdeparture.get(CONF_PRODUCTS),
- nextdeparture.get(CONF_TIMEOFFSET),
- nextdeparture.get(CONF_NUMBER),
- nextdeparture.get(CONF_NAME),
- )
- for nextdeparture in config[CONF_NEXT_DEPARTURE]
- ),
- True,
- )
+ sensors = [
+ MVGLiveSensor(
+ hass,
+ nextdeparture.get(CONF_STATION),
+ nextdeparture.get(CONF_DESTINATIONS),
+ nextdeparture.get(CONF_LINES),
+ nextdeparture.get(CONF_PRODUCTS),
+ nextdeparture.get(CONF_TIMEOFFSET),
+ nextdeparture.get(CONF_NUMBER),
+ nextdeparture.get(CONF_NAME),
+ )
+ for nextdeparture in config[CONF_NEXT_DEPARTURE]
+ ]
+ add_entities(sensors, True)
class MVGLiveSensor(SensorEntity):
@@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity):
def __init__(
self,
- station,
+ hass: HomeAssistant,
+ station_name,
destinations,
- directions,
lines,
products,
timeoffset,
number,
name,
- ):
+ ) -> None:
"""Initialize the sensor."""
- self._station = station
self._name = name
+ self._station_name = station_name
self.data = MVGLiveData(
- station, destinations, directions, lines, products, timeoffset, number
+ hass, station_name, destinations, lines, products, timeoffset, number
)
self._state = None
self._icon = ICONS["-"]
@property
- def name(self):
+ def name(self) -> str | None:
"""Return the name of the sensor."""
if self._name:
return self._name
- return self._station
+ return self._station_name
@property
- def native_value(self):
+ def native_value(self) -> str | None:
"""Return the next departure time."""
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
if not (dep := self.data.departures):
return None
@@ -140,88 +144,114 @@ class MVGLiveSensor(SensorEntity):
return attr
@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 this state is expressed in."""
return UnitOfTime.MINUTES
- def update(self) -> None:
+ async def async_update(self) -> None:
"""Get the latest data and update the state."""
- self.data.update()
+ await self.data.update()
if not self.data.departures:
- self._state = "-"
+ self._state = None
self._icon = ICONS["-"]
else:
- self._state = self.data.departures[0].get("time", "-")
- self._icon = ICONS[self.data.departures[0].get("product", "-")]
+ self._state = self.data.departures[0].get("time_in_mins", "-")
+ self._icon = self.data.departures[0].get("icon", ICONS["-"])
+
+
+def _get_minutes_until_departure(departure_time: int) -> int:
+ """Calculate the time difference in minutes between the current time and a given departure time.
+
+ Args:
+ departure_time: Unix timestamp of the departure time, in seconds.
+
+ Returns:
+ The time difference in minutes, as an integer.
+
+ """
+ current_time = dt_util.utcnow()
+ departure_datetime = dt_util.utc_from_timestamp(departure_time)
+ time_difference = (departure_datetime - current_time).total_seconds()
+ return int(time_difference / 60.0)
class MVGLiveData:
- """Pull data from the mvg-live.de web page."""
+ """Pull data from the mvg.de web page."""
def __init__(
- self, station, destinations, directions, lines, products, timeoffset, number
- ):
+ self,
+ hass: HomeAssistant,
+ station_name,
+ destinations,
+ lines,
+ products,
+ timeoffset,
+ number,
+ ) -> None:
"""Initialize the sensor."""
- self._station = station
+ self._hass = hass
+ self._station_name = station_name
+ self._station_id = None
self._destinations = destinations
- self._directions = directions
self._lines = lines
self._products = products
self._timeoffset = timeoffset
self._number = number
- self._include_ubahn = "U-Bahn" in self._products
- self._include_tram = "Tram" in self._products
- self._include_bus = "Bus" in self._products
- self._include_sbahn = "S-Bahn" in self._products
- self.mvg = MVGLive.MVGLive()
- self.departures = []
+ self.departures: list[dict[str, Any]] = []
- def update(self):
+ async def update(self):
"""Update the connection data."""
+ if self._station_id is None:
+ try:
+ station = await MvgApi.station_async(self._station_name)
+ self._station_id = station["id"]
+ except MvgApiError as err:
+ _LOGGER.error(
+ "Failed to resolve station %s: %s", self._station_name, err
+ )
+ self.departures = []
+ return
+
try:
- _departures = self.mvg.getlivedata(
- station=self._station,
- timeoffset=self._timeoffset,
- ubahn=self._include_ubahn,
- tram=self._include_tram,
- bus=self._include_bus,
- sbahn=self._include_sbahn,
+ _departures = await MvgApi.departures_async(
+ station_id=self._station_id,
+ offset=self._timeoffset,
+ limit=self._number,
+ transport_types=[
+ transport_type
+ for transport_type in TransportType
+ if transport_type.value[0] in self._products
+ ]
+ if self._products
+ else None,
)
except ValueError:
self.departures = []
_LOGGER.warning("Returned data not understood")
return
self.departures = []
- for i, _departure in enumerate(_departures):
- # find the first departure meeting the criteria
+ for _departure in _departures:
if (
"" not in self._destinations[:1]
and _departure["destination"] not in self._destinations
):
continue
- if (
- "" not in self._directions[:1]
- and _departure["direction"] not in self._directions
- ):
+ if "" not in self._lines[:1] and _departure["line"] not in self._lines:
continue
- if "" not in self._lines[:1] and _departure["linename"] not in self._lines:
+ time_to_departure = _get_minutes_until_departure(_departure["time"])
+
+ if time_to_departure < self._timeoffset:
continue
- if _departure["time"] < self._timeoffset:
- continue
-
- # now select the relevant data
_nextdep = {}
- for k in ("destination", "linename", "time", "direction", "product"):
+ for k in ("destination", "line", "type", "cancelled", "icon"):
_nextdep[k] = _departure.get(k, "")
- _nextdep["time"] = int(_nextdep["time"])
+ _nextdep["time_in_mins"] = time_to_departure
self.departures.append(_nextdep)
- if i == self._number - 1:
- break
diff --git a/homeassistant/components/nam/icons.json b/homeassistant/components/nam/icons.json
index 5e55bf145e5..594fd5fb5b7 100644
--- a/homeassistant/components/nam/icons.json
+++ b/homeassistant/components/nam/icons.json
@@ -18,9 +18,6 @@
},
"sps30_caqi_level": {
"default": "mdi:air-filter"
- },
- "sps30_pm4": {
- "default": "mdi:molecule"
}
}
}
diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py
index 45cfd313e8f..a7e5eb71912 100644
--- a/homeassistant/components/nam/sensor.py
+++ b/homeassistant/components/nam/sensor.py
@@ -324,6 +324,7 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = (
translation_key="sps30_pm4",
suggested_display_precision=0,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ device_class=SensorDeviceClass.PM4,
state_class=SensorStateClass.MEASUREMENT,
value=lambda sensors: sensors.sps30_p4,
),
diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py
index b052df36e34..9f7177f7432 100644
--- a/homeassistant/components/nederlandse_spoorwegen/__init__.py
+++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py
@@ -1 +1,56 @@
-"""The nederlandse_spoorwegen component."""
+"""The Nederlandse Spoorwegen integration."""
+
+from __future__ import annotations
+
+import logging
+
+from ns_api import NSAPI, RequestParametersError
+import requests
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type NSConfigEntry = ConfigEntry[NSAPI]
+
+PLATFORMS = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
+ """Set up Nederlandse Spoorwegen from a config entry."""
+ api_key = entry.data[CONF_API_KEY]
+
+ client = NSAPI(api_key)
+
+ try:
+ await hass.async_add_executor_job(client.get_stations)
+ except (
+ requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError,
+ ) as error:
+ _LOGGER.error("Could not connect to the internet: %s", error)
+ raise ConfigEntryNotReady from error
+ except RequestParametersError as error:
+ _LOGGER.error("Could not fetch stations, please check configuration: %s", error)
+ raise ConfigEntryNotReady from error
+
+ entry.runtime_data = client
+
+ entry.async_on_unload(entry.add_update_listener(async_reload_entry))
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_reload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> None:
+ """Reload NS integration when options are updated."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py
new file mode 100644
index 00000000000..f614e41a959
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py
@@ -0,0 +1,176 @@
+"""Config flow for Nederlandse Spoorwegen integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ns_api import NSAPI, Station
+from requests.exceptions import (
+ ConnectionError as RequestsConnectionError,
+ HTTPError,
+ Timeout,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryData,
+ ConfigSubentryFlow,
+ SubentryFlowResult,
+)
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import callback
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ TimeSelector,
+)
+
+from .const import (
+ CONF_FROM,
+ CONF_NAME,
+ CONF_ROUTES,
+ CONF_TIME,
+ CONF_TO,
+ CONF_VIA,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class NSConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Nederlandse Spoorwegen."""
+
+ VERSION = 1
+ MINOR_VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step of the config flow (API key)."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self._async_abort_entries_match(user_input)
+ client = NSAPI(user_input[CONF_API_KEY])
+ try:
+ await self.hass.async_add_executor_job(client.get_stations)
+ except HTTPError:
+ errors["base"] = "invalid_auth"
+ except (RequestsConnectionError, Timeout):
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception validating API key")
+ errors["base"] = "unknown"
+ if not errors:
+ return self.async_create_entry(
+ title="Nederlandse Spoorwegen",
+ data={CONF_API_KEY: user_input[CONF_API_KEY]},
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+ errors=errors,
+ )
+
+ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
+ """Handle import from YAML configuration."""
+ self._async_abort_entries_match({CONF_API_KEY: import_data[CONF_API_KEY]})
+
+ client = NSAPI(import_data[CONF_API_KEY])
+ try:
+ stations = await self.hass.async_add_executor_job(client.get_stations)
+ except HTTPError:
+ return self.async_abort(reason="invalid_auth")
+ except (RequestsConnectionError, Timeout):
+ return self.async_abort(reason="cannot_connect")
+ except Exception:
+ _LOGGER.exception("Unexpected exception validating API key")
+ return self.async_abort(reason="unknown")
+
+ station_codes = {station.code for station in stations}
+
+ subentries: list[ConfigSubentryData] = []
+ for route in import_data.get(CONF_ROUTES, []):
+ # Convert station codes to uppercase for consistency with UI routes
+ for key in (CONF_FROM, CONF_TO, CONF_VIA):
+ if key in route:
+ route[key] = route[key].upper()
+ if route[key] not in station_codes:
+ return self.async_abort(reason="invalid_station")
+
+ subentries.append(
+ ConfigSubentryData(
+ title=route[CONF_NAME],
+ subentry_type="route",
+ data=route,
+ unique_id=None,
+ )
+ )
+
+ return self.async_create_entry(
+ title="Nederlandse Spoorwegen",
+ data={CONF_API_KEY: import_data[CONF_API_KEY]},
+ subentries=subentries,
+ )
+
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {"route": RouteSubentryFlowHandler}
+
+
+class RouteSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying routes."""
+
+ def __init__(self) -> None:
+ """Initialize route subentry flow."""
+ self.stations: dict[str, Station] = {}
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add a new route subentry."""
+ if user_input is not None:
+ return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
+ client = NSAPI(self._get_entry().data[CONF_API_KEY])
+ if not self.stations:
+ try:
+ self.stations = {
+ station.code: station
+ for station in await self.hass.async_add_executor_job(
+ client.get_stations
+ )
+ }
+ except (RequestsConnectionError, Timeout, HTTPError, ValueError):
+ return self.async_abort(reason="cannot_connect")
+
+ options = [
+ SelectOptionDict(label=station.names["long"], value=code)
+ for code, station in self.stations.items()
+ ]
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_NAME): str,
+ vol.Required(CONF_FROM): SelectSelector(
+ SelectSelectorConfig(options=options, sort=True),
+ ),
+ vol.Required(CONF_TO): SelectSelector(
+ SelectSelectorConfig(options=options, sort=True),
+ ),
+ vol.Optional(CONF_VIA): SelectSelector(
+ SelectSelectorConfig(options=options, sort=True),
+ ),
+ vol.Optional(CONF_TIME): TimeSelector(),
+ }
+ ),
+ )
diff --git a/homeassistant/components/nederlandse_spoorwegen/const.py b/homeassistant/components/nederlandse_spoorwegen/const.py
new file mode 100644
index 00000000000..3c350ed22ae
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/const.py
@@ -0,0 +1,17 @@
+"""Constants for the Nederlandse Spoorwegen integration."""
+
+DOMAIN = "nederlandse_spoorwegen"
+
+CONF_ROUTES = "routes"
+CONF_FROM = "from"
+CONF_TO = "to"
+CONF_VIA = "via"
+CONF_TIME = "time"
+CONF_NAME = "name"
+
+# Attribute and schema keys
+ATTR_ROUTE = "route"
+ATTR_TRIPS = "trips"
+ATTR_FIRST_TRIP = "first_trip"
+ATTR_NEXT_TRIP = "next_trip"
+ATTR_ROUTES = "routes"
diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json
index 0ef9d8d86f3..1f415dc695d 100644
--- a/homeassistant/components/nederlandse_spoorwegen/manifest.json
+++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json
@@ -1,8 +1,10 @@
{
"domain": "nederlandse_spoorwegen",
"name": "Nederlandse Spoorwegen (NS)",
- "codeowners": ["@YarmoM"],
+ "codeowners": ["@YarmoM", "@heindrichpaul"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
+ "integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": ["nsapi==3.1.2"]
diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py
index 1e7fc54f4f7..67dc43cfa25 100644
--- a/homeassistant/components/nederlandse_spoorwegen/sensor.py
+++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py
@@ -2,11 +2,12 @@
from __future__ import annotations
+import datetime as dt
from datetime import datetime, timedelta
import logging
+from typing import Any
-import ns_api
-from ns_api import RequestParametersError
+from ns_api import NSAPI, Trip
import requests
import voluptuous as vol
@@ -14,13 +15,21 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_NAME
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+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.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
+from homeassistant.util.dt import parse_time
+
+from . import NSConfigEntry
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -50,57 +59,84 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
)
-def setup_platform(
+async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
- add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the departure sensor."""
- nsapi = ns_api.NSAPI(config[CONF_API_KEY])
-
- try:
- stations = nsapi.get_stations()
- except (
- requests.exceptions.ConnectionError,
- requests.exceptions.HTTPError,
- ) as error:
- _LOGGER.error("Could not connect to the internet: %s", error)
- raise PlatformNotReady from error
- except RequestParametersError as error:
- _LOGGER.error("Could not fetch stations, please check configuration: %s", error)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=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.4.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": "Nederlandse Spoorwegen",
+ },
+ )
return
- sensors = []
- for departure in config.get(CONF_ROUTES, {}):
- if not valid_stations(
- stations,
- [departure.get(CONF_FROM), departure.get(CONF_VIA), departure.get(CONF_TO)],
- ):
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ "deprecated_yaml",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Nederlandse Spoorwegen",
+ },
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: NSConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the departure sensor from a config entry."""
+
+ client = config_entry.runtime_data
+
+ for subentry in config_entry.subentries.values():
+ if subentry.subentry_type != "route":
continue
- sensors.append(
- NSDepartureSensor(
- nsapi,
- departure.get(CONF_NAME),
- departure.get(CONF_FROM),
- departure.get(CONF_TO),
- departure.get(CONF_VIA),
- departure.get(CONF_TIME),
- )
+
+ async_add_entities(
+ [
+ NSDepartureSensor(
+ client,
+ subentry.data[CONF_NAME],
+ subentry.data[CONF_FROM],
+ subentry.data[CONF_TO],
+ subentry.data.get(CONF_VIA),
+ parse_time(subentry.data[CONF_TIME])
+ if CONF_TIME in subentry.data
+ else None,
+ )
+ ],
+ config_subentry_id=subentry.subentry_id,
+ update_before_add=True,
)
- add_entities(sensors, True)
-
-
-def valid_stations(stations, given_stations):
- """Verify the existence of the given station codes."""
- for station in given_stations:
- if station is None:
- continue
- if not any(s.code == station.upper() for s in stations):
- _LOGGER.warning("Station '%s' is not a valid station", station)
- return False
- return True
class NSDepartureSensor(SensorEntity):
@@ -109,7 +145,15 @@ class NSDepartureSensor(SensorEntity):
_attr_attribution = "Data provided by NS"
_attr_icon = "mdi:train"
- def __init__(self, nsapi, name, departure, heading, via, time):
+ def __init__(
+ self,
+ nsapi: NSAPI,
+ name: str,
+ departure: str,
+ heading: str,
+ via: str | None,
+ time: dt.time | None,
+ ) -> None:
"""Initialize the sensor."""
self._nsapi = nsapi
self._name = name
@@ -117,23 +161,23 @@ class NSDepartureSensor(SensorEntity):
self._via = via
self._heading = heading
self._time = time
- self._state = None
- self._trips = None
- self._first_trip = None
- self._next_trip = None
+ self._state: str | None = None
+ self._trips: list[Trip] | None = None
+ self._first_trip: Trip | None = None
+ self._next_trip: Trip | None = None
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
- def native_value(self):
+ def native_value(self) -> str | None:
"""Return the next departure time."""
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if not self._trips or self._first_trip is None:
return None
@@ -203,6 +247,7 @@ class NSDepartureSensor(SensorEntity):
):
attributes["arrival_delay"] = True
+ assert self._next_trip is not None
# Next attributes
if self._next_trip.departure_time_actual is not None:
attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M")
diff --git a/homeassistant/components/nederlandse_spoorwegen/strings.json b/homeassistant/components/nederlandse_spoorwegen/strings.json
new file mode 100644
index 00000000000..0da1fd1ccc2
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/strings.json
@@ -0,0 +1,74 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Set up your Nederlandse Spoorwegen integration.",
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "Your NS API key."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Could not connect to NS API. Check your API key.",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_api_key%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ }
+ },
+ "config_subentries": {
+ "route": {
+ "step": {
+ "user": {
+ "description": "Select your departure and destination stations from the dropdown lists.",
+ "data": {
+ "name": "Route name",
+ "from": "Departure station",
+ "to": "Destination station",
+ "via": "Via station",
+ "time": "Departure time"
+ },
+ "data_description": {
+ "name": "A name for this route",
+ "from": "The station to depart from",
+ "to": "The station to arrive at",
+ "via": "An optional intermediate station",
+ "time": "Optional planned departure time"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "initiate_flow": {
+ "user": "Add route"
+ },
+ "entry_type": "Route"
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_invalid_auth": {
+ "title": "Nederlandse Spoorwegen YAML configuration deprecated",
+ "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an invalid API key was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI."
+ },
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]",
+ "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the NS API. Please check your internet connection and the status of the NS API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI."
+ },
+ "deprecated_yaml_import_issue_unknown": {
+ "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]",
+ "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an unknown error occurred. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI."
+ },
+ "deprecated_yaml_import_issue_invalid_station": {
+ "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]",
+ "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration an invalid station was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI."
+ }
+ }
+}
diff --git a/homeassistant/components/neo/__init__.py b/homeassistant/components/neo/__init__.py
new file mode 100644
index 00000000000..613f57c0703
--- /dev/null
+++ b/homeassistant/components/neo/__init__.py
@@ -0,0 +1 @@
+"""Neo virtual integration."""
diff --git a/homeassistant/components/neo/manifest.json b/homeassistant/components/neo/manifest.json
new file mode 100644
index 00000000000..9f934a60309
--- /dev/null
+++ b/homeassistant/components/neo/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "neo",
+ "name": "Neo",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+}
diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json
index 79227e8564b..0b032fc24f6 100644
--- a/homeassistant/components/ness_alarm/manifest.json
+++ b/homeassistant/components/ness_alarm/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["nessclient"],
"quality_scale": "legacy",
- "requirements": ["nessclient==1.2.0"]
+ "requirements": ["nessclient==1.3.1"]
}
diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json
index 939b0b62284..310091639c7 100644
--- a/homeassistant/components/nexia/manifest.json
+++ b/homeassistant/components/nexia/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling",
"loggers": ["nexia"],
- "requirements": ["nexia==2.10.0"]
+ "requirements": ["nexia==2.11.1"]
}
diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json
index 4fdbcdb7175..27c663aedc7 100644
--- a/homeassistant/components/nextdns/manifest.json
+++ b/homeassistant/components/nextdns/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
+ "quality_scale": "bronze",
"requirements": ["nextdns==4.1.0"]
}
diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml
new file mode 100644
index 00000000000..898a9b3055a
--- /dev/null
+++ b/homeassistant/components/nextdns/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not register services.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not register services.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: The integration does not register services.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: Patch NextDns object instead of functions.
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: The integration is a cloud service and thus does not support discovery.
+ discovery:
+ status: exempt
+ comment: The integration is a cloud service and thus does not support discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations:
+ status: todo
+ comment: Add info that there are no known limitations.
+ docs-supported-devices:
+ status: exempt
+ comment: This is a service, which doesn't integrate with any devices.
+ docs-supported-functions: todo
+ docs-troubleshooting:
+ status: exempt
+ comment: No known issues that could be resolved by the user.
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: This integration has a fixed single service.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: Allow API key to be changed in the re-configure flow.
+ repair-issues:
+ status: exempt
+ comment: This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: This integration has a fixed single service.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json
index a8441fb90d8..05bb0b28943 100644
--- a/homeassistant/components/nibe_heatpump/manifest.json
+++ b/homeassistant/components/nibe_heatpump/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling",
- "requirements": ["nibe==2.17.0"]
+ "requirements": ["nibe==2.19.0"]
}
diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py
index a49549996b9..f755670814a 100644
--- a/homeassistant/components/niko_home_control/config_flow.py
+++ b/homeassistant/components/niko_home_control/config_flow.py
@@ -39,6 +39,25 @@ class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN):
MINOR_VERSION = 2
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ error = await test_connection(user_input[CONF_HOST])
+ if not error:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates=user_input,
+ )
+ errors["base"] = error
+
+ return self.async_show_form(
+ step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json
index 6e2b50d4736..2abb5b71f46 100644
--- a/homeassistant/components/niko_home_control/strings.json
+++ b/homeassistant/components/niko_home_control/strings.json
@@ -9,13 +9,22 @@
"data_description": {
"host": "The hostname or IP address of the Niko Home Control controller."
}
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "[%key:component::niko_home_control::config::step::user::data_description::host%]"
+ }
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}
diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json
index 98ea88d8798..99acc636bd6 100644
--- a/homeassistant/components/nina/strings.json
+++ b/homeassistant/components/nina/strings.json
@@ -11,7 +11,7 @@
"_r_to_u": "City/county (R-U)",
"_v_to_z": "City/county (V-Z)",
"slots": "Maximum warnings per city/county",
- "headline_filter": "Blacklist regex to filter warning headlines"
+ "headline_filter": "Headline blocklist"
}
}
},
@@ -34,7 +34,7 @@
"_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]",
"slots": "[%key:component::nina::config::step::user::data::slots%]",
"headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]",
- "area_filter": "Whitelist regex to filter warnings based on affected areas"
+ "area_filter": "Affected area filter"
}
}
},
diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py
index 617f84e8aca..a46cbf46443 100644
--- a/homeassistant/components/nmap_tracker/const.py
+++ b/homeassistant/components/nmap_tracker/const.py
@@ -13,6 +13,6 @@ NMAP_TRACKED_DEVICES: Final = "nmap_tracked_devices"
# Interval in minutes to exclude devices from a scan while they are home
CONF_HOME_INTERVAL: Final = "home_interval"
CONF_OPTIONS: Final = "scan_options"
-DEFAULT_OPTIONS: Final = "-F -T4 --min-rate 10 --host-timeout 5s"
+DEFAULT_OPTIONS: Final = "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s"
TRACKER_SCAN_INTERVAL: Final = 120
diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py
index 77f4b263b54..8fb6a5eaf3b 100644
--- a/homeassistant/components/nordpool/__init__.py
+++ b/homeassistant/components/nordpool/__init__.py
@@ -33,7 +33,8 @@ async def async_setup_entry(
await cleanup_device(hass, config_entry)
coordinator = NordPoolDataUpdateCoordinator(hass, config_entry)
- await coordinator.fetch_data(dt_util.utcnow())
+ await coordinator.fetch_data(dt_util.utcnow(), True)
+ await coordinator.update_listeners(dt_util.utcnow())
if not coordinator.last_update_success:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py
index d2edb81b9e6..f2f41322aff 100644
--- a/homeassistant/components/nordpool/coordinator.py
+++ b/homeassistant/components/nordpool/coordinator.py
@@ -13,7 +13,6 @@ from pynordpool import (
DeliveryPeriodEntry,
DeliveryPeriodsData,
NordPoolClient,
- NordPoolEmptyResponseError,
NordPoolError,
NordPoolResponseError,
)
@@ -22,7 +21,7 @@ from homeassistant.const import CONF_CURRENCY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import CONF_AREAS, DOMAIN, LOGGER
@@ -45,9 +44,10 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
name=DOMAIN,
)
self.client = NordPoolClient(session=async_get_clientsession(hass))
- self.unsub: Callable[[], None] | None = None
+ self.data_unsub: Callable[[], None] | None = None
+ self.listener_unsub: Callable[[], None] | None = None
- def get_next_interval(self, now: datetime) -> datetime:
+ def get_next_data_interval(self, now: datetime) -> datetime:
"""Compute next time an update should occur."""
next_hour = dt_util.utcnow() + timedelta(hours=1)
next_run = datetime(
@@ -57,24 +57,71 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
next_hour.hour,
tzinfo=dt_util.UTC,
)
- LOGGER.debug("Next update at %s", next_run)
+ LOGGER.debug("Next data update at %s", next_run)
+ return next_run
+
+ def get_next_15_interval(self, now: datetime) -> datetime:
+ """Compute next time we need to notify listeners."""
+ next_run = dt_util.utcnow() + timedelta(minutes=15)
+ next_minute = next_run.minute // 15 * 15
+ next_run = next_run.replace(
+ minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC
+ )
+
+ LOGGER.debug("Next listener update at %s", next_run)
return next_run
async def async_shutdown(self) -> None:
"""Cancel any scheduled call, and ignore new runs."""
await super().async_shutdown()
- if self.unsub:
- self.unsub()
- self.unsub = None
+ if self.data_unsub:
+ self.data_unsub()
+ self.data_unsub = None
+ if self.listener_unsub:
+ self.listener_unsub()
+ self.listener_unsub = None
- async def fetch_data(self, now: datetime) -> None:
- """Fetch data from Nord Pool."""
- self.unsub = async_track_point_in_utc_time(
- self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow())
+ async def update_listeners(self, now: datetime) -> None:
+ """Update entity listeners."""
+ self.listener_unsub = async_track_point_in_utc_time(
+ self.hass,
+ self.update_listeners,
+ self.get_next_15_interval(dt_util.utcnow()),
)
+ self.async_update_listeners()
+
+ async def fetch_data(self, now: datetime, initial: bool = False) -> None:
+ """Fetch data from Nord Pool."""
+ self.data_unsub = async_track_point_in_utc_time(
+ self.hass, self.fetch_data, self.get_next_data_interval(dt_util.utcnow())
+ )
+ if self.config_entry.pref_disable_polling and not initial:
+ return
+ try:
+ data = await self.handle_data(initial)
+ except UpdateFailed as err:
+ self.async_set_update_error(err)
+ return
+ self.async_set_updated_data(data)
+
+ async def handle_data(self, initial: bool = False) -> DeliveryPeriodsData:
+ """Fetch data from Nord Pool."""
data = await self.api_call()
if data and data.entries:
- self.async_set_updated_data(data)
+ current_day = dt_util.utcnow().strftime("%Y-%m-%d")
+ for entry in data.entries:
+ if entry.requested_date == current_day:
+ LOGGER.debug("Data for current day found")
+ return data
+ if data and not data.entries and not initial:
+ # Empty response, use cache
+ LOGGER.debug("No data entries received")
+ return self.data
+ raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_day_data")
+
+ async def _async_update_data(self) -> DeliveryPeriodsData:
+ """Fetch the latest data from the source."""
+ return await self.handle_data()
async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None:
"""Make api call to retrieve data with retry if failure."""
@@ -96,16 +143,16 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
aiohttp.ClientError,
) as error:
LOGGER.debug("Connection error: %s", error)
- self.async_set_update_error(error)
+ if self.data is None:
+ self.async_set_update_error( # type: ignore[unreachable]
+ UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="could_not_fetch_data",
+ translation_placeholders={"error": str(error)},
+ )
+ )
+ return self.data
- if data:
- current_day = dt_util.utcnow().strftime("%Y-%m-%d")
- for entry in data.entries:
- if entry.requested_date == current_day:
- LOGGER.debug("Data for current day found")
- return data
-
- self.async_set_update_error(NordPoolEmptyResponseError("No current day data"))
return data
def merge_price_entries(self) -> list[DeliveryPeriodEntry]:
diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json
index ca299b470ea..fe4bcf7c2c9 100644
--- a/homeassistant/components/nordpool/manifest.json
+++ b/homeassistant/components/nordpool/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["pynordpool"],
"quality_scale": "platinum",
- "requirements": ["pynordpool==0.3.0"],
+ "requirements": ["pynordpool==0.3.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py
index 4bde12afc3c..90b0f44c2e5 100644
--- a/homeassistant/components/nordpool/sensor.py
+++ b/homeassistant/components/nordpool/sensor.py
@@ -34,8 +34,11 @@ def validate_prices(
index: int,
) -> float | None:
"""Validate and return."""
- if (result := func(entity)[area][index]) is not None:
- return result / 1000
+ try:
+ if (result := func(entity)[area][index]) is not None:
+ return result / 1000
+ except KeyError:
+ return None
return None
diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json
index 3494996af01..18e019ee90a 100644
--- a/homeassistant/components/nordpool/strings.json
+++ b/homeassistant/components/nordpool/strings.json
@@ -157,6 +157,12 @@
},
"connection_error": {
"message": "There was a connection error connecting to the API. Try again later."
+ },
+ "no_day_data": {
+ "message": "Data for current day is missing"
+ },
+ "could_not_fetch_data": {
+ "message": "Data could not be retrieved: {error}"
}
}
}
diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py
index 72dbb4d2afb..ccaf50ebef1 100644
--- a/homeassistant/components/ntfy/__init__.py
+++ b/homeassistant/components/ntfy/__init__.py
@@ -21,7 +21,7 @@ from .const import DOMAIN
from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
-PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool:
diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py
index ed8d56820c2..5f168c977c4 100644
--- a/homeassistant/components/ntfy/config_flow.py
+++ b/homeassistant/components/ntfy/config_flow.py
@@ -38,12 +38,25 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
-from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH
+from .const import (
+ CONF_MESSAGE,
+ CONF_PRIORITY,
+ CONF_TAGS,
+ CONF_TITLE,
+ CONF_TOPIC,
+ DEFAULT_URL,
+ DOMAIN,
+ SECTION_AUTH,
+ SECTION_FILTER,
+)
_LOGGER = logging.getLogger(__name__)
@@ -108,10 +121,36 @@ STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
}
)
+TOPIC_FILTER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_PRIORITY): SelectSelector(
+ SelectSelectorConfig(
+ multiple=True,
+ options=["5", "4", "3", "2", "1"],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="priority",
+ )
+ ),
+ vol.Optional(CONF_TAGS): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.TEXT,
+ multiple=True,
+ ),
+ ),
+ vol.Optional(CONF_TITLE): str,
+ vol.Optional(CONF_MESSAGE): str,
+ }
+)
+
+
STEP_USER_TOPIC_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOPIC): str,
vol.Optional(CONF_NAME): str,
+ vol.Required(SECTION_FILTER): data_entry_flow.section(
+ TOPIC_FILTER_SCHEMA,
+ {"collapsed": True},
+ ),
}
)
@@ -408,7 +447,10 @@ class TopicSubentryFlowHandler(ConfigSubentryFlow):
return self.async_create_entry(
title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]),
- data=user_input,
+ data={
+ CONF_TOPIC: user_input[CONF_TOPIC],
+ **user_input[SECTION_FILTER],
+ },
unique_id=user_input[CONF_TOPIC],
)
return self.async_show_form(
@@ -418,3 +460,32 @@ class TopicSubentryFlowHandler(ConfigSubentryFlow):
),
errors=errors,
)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure flow to modify an existing topic."""
+ entry = self._get_entry()
+ subentry = self._get_reconfigure_subentry()
+ subentry_data = entry.subentries[subentry.subentry_id].data
+
+ if user_input is not None:
+ return self.async_update_and_abort(
+ entry=entry,
+ subentry=subentry,
+ data_updates={
+ CONF_PRIORITY: user_input.get(CONF_PRIORITY),
+ CONF_TAGS: user_input.get(CONF_TAGS),
+ CONF_TITLE: user_input.get(CONF_TITLE),
+ CONF_MESSAGE: user_input.get(CONF_MESSAGE),
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=TOPIC_FILTER_SCHEMA,
+ suggested_values=subentry_data,
+ ),
+ description_placeholders={CONF_TOPIC: subentry_data[CONF_TOPIC]},
+ )
diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py
index 78355f7e828..5fb500917d6 100644
--- a/homeassistant/components/ntfy/const.py
+++ b/homeassistant/components/ntfy/const.py
@@ -6,4 +6,10 @@ DOMAIN = "ntfy"
DEFAULT_URL: Final = "https://ntfy.sh"
CONF_TOPIC = "topic"
+CONF_PRIORITY = "filter_priority"
+CONF_TITLE = "filter_title"
+CONF_MESSAGE = "filter_message"
+CONF_TAGS = "filter_tags"
SECTION_AUTH = "auth"
+SECTION_FILTER = "filter"
+NTFY_EVENT = "ntfy_event"
diff --git a/homeassistant/components/ntfy/entity.py b/homeassistant/components/ntfy/entity.py
new file mode 100644
index 00000000000..d03d953799f
--- /dev/null
+++ b/homeassistant/components/ntfy/entity.py
@@ -0,0 +1,43 @@
+"""Base entity for ntfy integration."""
+
+from __future__ import annotations
+
+from yarl import URL
+
+from homeassistant.config_entries import ConfigSubentry
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from .const import CONF_TOPIC, DOMAIN
+from .coordinator import NtfyConfigEntry
+
+
+class NtfyBaseEntity(Entity):
+ """Base entity."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ config_entry: NtfyConfigEntry,
+ subentry: ConfigSubentry,
+ ) -> None:
+ """Initialize the entity."""
+ self.topic = subentry.data[CONF_TOPIC]
+
+ self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}"
+
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ manufacturer="ntfy LLC",
+ model="ntfy",
+ name=subentry.title,
+ configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
+ identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
+ via_device=(DOMAIN, config_entry.entry_id),
+ )
+ self.ntfy = config_entry.runtime_data.ntfy
+ self.config_entry = config_entry
+ self.subentry = subentry
diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py
new file mode 100644
index 00000000000..8f5d8d7b621
--- /dev/null
+++ b/homeassistant/components/ntfy/event.py
@@ -0,0 +1,172 @@
+"""Event platform for ntfy integration."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING
+
+from aiontfy import Event, Notification
+from aiontfy.exceptions import (
+ NtfyConnectionError,
+ NtfyForbiddenError,
+ NtfyHTTPError,
+ NtfyTimeoutError,
+)
+
+from homeassistant.components.event import EventEntity, EventEntityDescription
+from homeassistant.config_entries import ConfigSubentry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import (
+ CONF_MESSAGE,
+ CONF_PRIORITY,
+ CONF_TAGS,
+ CONF_TITLE,
+ CONF_TOPIC,
+ DOMAIN,
+)
+from .coordinator import NtfyConfigEntry
+from .entity import NtfyBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+RECONNECT_INTERVAL = 10
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: NtfyConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the event platform."""
+
+ for subentry_id, subentry in config_entry.subentries.items():
+ async_add_entities(
+ [NtfyEventEntity(config_entry, subentry)], config_subentry_id=subentry_id
+ )
+
+
+class NtfyEventEntity(NtfyBaseEntity, EventEntity):
+ """An event entity."""
+
+ entity_description = EventEntityDescription(
+ key="subscribe",
+ translation_key="subscribe",
+ name=None,
+ event_types=["triggered"],
+ )
+
+ def __init__(
+ self,
+ config_entry: NtfyConfigEntry,
+ subentry: ConfigSubentry,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(config_entry, subentry)
+ self._ws: asyncio.Task | None = None
+
+ @callback
+ def _async_handle_event(self, notification: Notification) -> None:
+ """Handle the ntfy event."""
+ if notification.topic == self.topic and notification.event is Event.MESSAGE:
+ event = (
+ f"{notification.title}: {notification.message}"
+ if notification.title
+ else notification.message
+ )
+ if TYPE_CHECKING:
+ assert event
+ self._attr_event_types = [event]
+ self._trigger_event(event, notification.to_dict())
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self.ws_connect(),
+ "websocket_watchdog",
+ )
+
+ async def ws_connect(self) -> None:
+ """Connect websocket."""
+ while True:
+ try:
+ if self._ws and (exc := self._ws.exception()):
+ raise exc # noqa: TRY301
+ except asyncio.InvalidStateError:
+ self._attr_available = True
+ except asyncio.CancelledError:
+ self._attr_available = False
+ return
+ except NtfyForbiddenError:
+ if self._attr_available:
+ _LOGGER.error(
+ "Failed to subscribe to topic %s. Topic is protected",
+ self.topic,
+ )
+ self._attr_available = False
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"topic_protected_{self.topic}",
+ is_fixable=True,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="topic_protected",
+ translation_placeholders={CONF_TOPIC: self.topic},
+ data={"entity_id": self.entity_id, "topic": self.topic},
+ )
+ return
+ except NtfyHTTPError as e:
+ if self._attr_available:
+ _LOGGER.error(
+ "Failed to connect to ntfy service due to a server error: %s (%s)",
+ e.error,
+ e.link,
+ )
+ self._attr_available = False
+ except NtfyConnectionError:
+ if self._attr_available:
+ _LOGGER.error(
+ "Failed to connect to ntfy service due to a connection error"
+ )
+ self._attr_available = False
+ except NtfyTimeoutError:
+ if self._attr_available:
+ _LOGGER.error(
+ "Failed to connect to ntfy service due to a connection timeout"
+ )
+ self._attr_available = False
+ except Exception:
+ if self._attr_available:
+ _LOGGER.exception(
+ "Failed to connect to ntfy service due to an unexpected exception"
+ )
+ self._attr_available = False
+ finally:
+ self.async_write_ha_state()
+ if self._ws is None or self._ws.done():
+ self._ws = self.config_entry.async_create_background_task(
+ self.hass,
+ target=self.ntfy.subscribe(
+ topics=[self.topic],
+ callback=self._async_handle_event,
+ title=self.subentry.data.get(CONF_TITLE),
+ message=self.subentry.data.get(CONF_MESSAGE),
+ priority=self.subentry.data.get(CONF_PRIORITY),
+ tags=self.subentry.data.get(CONF_TAGS),
+ ),
+ name="ntfy_websocket",
+ )
+ await asyncio.sleep(RECONNECT_INTERVAL)
+
+ @property
+ def entity_picture(self) -> str | None:
+ """Return the entity picture to use in the frontend, if any."""
+
+ return self.state_attributes.get("icon") or super().entity_picture
diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json
index 66489413b5b..4b04a16f69f 100644
--- a/homeassistant/components/ntfy/icons.json
+++ b/homeassistant/components/ntfy/icons.json
@@ -5,6 +5,11 @@
"default": "mdi:console-line"
}
},
+ "event": {
+ "subscribe": {
+ "default": "mdi:message-outline"
+ }
+ },
"sensor": {
"messages": {
"default": "mdi:message-arrow-right-outline"
@@ -67,5 +72,10 @@
"default": "mdi:star"
}
}
+ },
+ "services": {
+ "publish": {
+ "service": "mdi:send"
+ }
}
}
diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json
index f041b02b6d6..279d30d9f9f 100644
--- a/homeassistant/components/ntfy/manifest.json
+++ b/homeassistant/components/ntfy/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/ntfy",
"iot_class": "cloud_push",
"loggers": ["aionfty"],
- "quality_scale": "bronze",
- "requirements": ["aiontfy==0.5.4"]
+ "quality_scale": "platinum",
+ "requirements": ["aiontfy==0.6.1"]
}
diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py
index e10e64caf23..176dddd7a44 100644
--- a/homeassistant/components/ntfy/notify.py
+++ b/homeassistant/components/ntfy/notify.py
@@ -2,32 +2,68 @@
from __future__ import annotations
+from datetime import timedelta
+from typing import Any
+
from aiontfy import Message
from aiontfy.exceptions import (
NtfyException,
NtfyHTTPError,
NtfyUnauthorizedAuthenticationError,
)
+import voluptuous as vol
from yarl import URL
from homeassistant.components.notify import (
+ ATTR_MESSAGE,
+ ATTR_TITLE,
NotifyEntity,
NotifyEntityDescription,
NotifyEntityFeature,
)
-from homeassistant.config_entries import ConfigSubentry
-from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import CONF_TOPIC, DOMAIN
+from .const import DOMAIN
from .coordinator import NtfyConfigEntry
+from .entity import NtfyBaseEntity
PARALLEL_UPDATES = 0
+SERVICE_PUBLISH = "publish"
+ATTR_ATTACH = "attach"
+ATTR_CALL = "call"
+ATTR_CLICK = "click"
+ATTR_DELAY = "delay"
+ATTR_EMAIL = "email"
+ATTR_ICON = "icon"
+ATTR_MARKDOWN = "markdown"
+ATTR_PRIORITY = "priority"
+ATTR_TAGS = "tags"
+
+SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
+ {
+ vol.Optional(ATTR_TITLE): cv.string,
+ vol.Optional(ATTR_MESSAGE): cv.string,
+ vol.Optional(ATTR_MARKDOWN): cv.boolean,
+ vol.Optional(ATTR_TAGS): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_PRIORITY): vol.All(vol.Coerce(int), vol.Range(1, 5)),
+ vol.Optional(ATTR_CLICK): vol.All(vol.Url(), vol.Coerce(URL)),
+ vol.Optional(ATTR_DELAY): vol.All(
+ cv.time_period,
+ vol.Range(min=timedelta(seconds=10), max=timedelta(days=3)),
+ ),
+ vol.Optional(ATTR_ATTACH): vol.All(vol.Url(), vol.Coerce(URL)),
+ vol.Optional(ATTR_EMAIL): vol.Email(),
+ vol.Optional(ATTR_CALL): cv.string,
+ vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
+ }
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NtfyConfigEntry,
@@ -40,43 +76,47 @@ async def async_setup_entry(
[NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id
)
+ platform = entity_platform.async_get_current_platform()
+ platform.async_register_entity_service(
+ SERVICE_PUBLISH,
+ SERVICE_PUBLISH_SCHEMA,
+ "publish",
+ )
-class NtfyNotifyEntity(NotifyEntity):
+
+class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity):
"""Representation of a ntfy notification entity."""
entity_description = NotifyEntityDescription(
key="publish",
translation_key="publish",
name=None,
- has_entity_name=True,
)
_attr_supported_features = NotifyEntityFeature.TITLE
- def __init__(
- self,
- config_entry: NtfyConfigEntry,
- subentry: ConfigSubentry,
- ) -> None:
- """Initialize a notification entity."""
-
- self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}"
- self.topic = subentry.data[CONF_TOPIC]
-
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- manufacturer="ntfy LLC",
- model="ntfy",
- name=subentry.data.get(CONF_NAME, self.topic),
- configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
- identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
- via_device=(DOMAIN, config_entry.entry_id),
- )
- self.config_entry = config_entry
- self.ntfy = config_entry.runtime_data.ntfy
-
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Publish a message to a topic."""
- msg = Message(topic=self.topic, message=message, title=title)
+ await self.publish(message=message, title=title)
+
+ async def publish(self, **kwargs: Any) -> None:
+ """Publish a message to a topic."""
+
+ params: dict[str, Any] = kwargs
+ delay: timedelta | None = params.get("delay")
+ if delay:
+ params["delay"] = f"{delay.total_seconds()}s"
+ if params.get("email"):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="delay_no_email",
+ )
+ if params.get("call"):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="delay_no_call",
+ )
+
+ msg = Message(topic=self.topic, **params)
try:
await self.ntfy.publish(msg)
except NtfyUnauthorizedAuthenticationError as e:
diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml
index 43a96135baf..6168628c2b7 100644
--- a/homeassistant/components/ntfy/quality_scale.yaml
+++ b/homeassistant/components/ntfy/quality_scale.yaml
@@ -3,25 +3,17 @@ rules:
action-setup:
status: exempt
comment: only entity actions
- appropriate-polling:
- status: exempt
- comment: the integration does not poll
+ appropriate-polling: done
brands: done
- common-modules:
- status: exempt
- comment: the integration currently implements only one platform and has no coordinator
+ common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
- docs-actions:
- status: exempt
- comment: integration has only entity actions
+ docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
- entity-event-setup:
- status: exempt
- comment: the integration does not subscribe to events
+ entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
@@ -36,13 +28,9 @@ rules:
status: exempt
comment: the integration has no options
docs-installation-parameters: done
- entity-unavailable:
- status: exempt
- comment: the integration only implements a stateless notify entity.
+ entity-unavailable: done
integration-owner: done
- log-when-unavailable:
- status: exempt
- comment: the integration only integrates state-less entities
+ log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
@@ -50,32 +38,32 @@ rules:
# Gold
devices: done
diagnostics: done
- discovery-update-info: todo
- discovery: todo
- docs-data-update: todo
- docs-examples: todo
- docs-known-limitations: todo
- docs-supported-devices: todo
- docs-supported-functions: todo
- docs-troubleshooting: todo
- docs-use-cases: todo
- dynamic-devices: todo
+ discovery-update-info:
+ status: exempt
+ comment: the service cannot be discovered
+ discovery:
+ status: exempt
+ comment: the service cannot be discovered
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: the integration is a service
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: devices are added manually as subentries
entity-category: done
- entity-device-class:
- status: exempt
- comment: no suitable device class for the notify entity
- entity-disabled-by-default:
- status: exempt
- comment: only one entity
- entity-translations:
- status: exempt
- comment: the notify entity uses the device name as entity name, no translation required
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues:
- status: exempt
- comment: the integration has no repairs
+ repair-issues: done
stale-devices:
status: exempt
comment: only one device per entry, is deleted with the entry.
diff --git a/homeassistant/components/ntfy/repairs.py b/homeassistant/components/ntfy/repairs.py
new file mode 100644
index 00000000000..e87ca3ddcad
--- /dev/null
+++ b/homeassistant/components/ntfy/repairs.py
@@ -0,0 +1,56 @@
+"""Repairs for ntfy integration."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from .const import CONF_TOPIC
+
+
+class TopicProtectedRepairFlow(RepairsFlow):
+ """Handler for protected topic issue fixing flow."""
+
+ def __init__(self, data: dict[str, str]) -> None:
+ """Initialize."""
+ self.entity_id = data["entity_id"]
+ self.topic = data["topic"]
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Init repair flow."""
+
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Confirm repair flow."""
+ if user_input is not None:
+ er.async_get(self.hass).async_update_entity(
+ self.entity_id,
+ disabled_by=er.RegistryEntryDisabler.USER,
+ )
+ return self.async_create_entry(data={})
+
+ return self.async_show_form(
+ step_id="confirm",
+ data_schema=vol.Schema({}),
+ description_placeholders={CONF_TOPIC: self.topic},
+ )
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str],
+) -> RepairsFlow:
+ """Create flow."""
+ if issue_id.startswith("topic_protected"):
+ return TopicProtectedRepairFlow(data)
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py
index 0180d9fce72..8948dc3f5f6 100644
--- a/homeassistant/components/ntfy/sensor.py
+++ b/homeassistant/components/ntfy/sensor.py
@@ -163,7 +163,7 @@ SENSOR_DESCRIPTIONS: tuple[NtfySensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
- suggested_display_precision=0,
+ suggested_display_precision=2,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING,
@@ -172,7 +172,7 @@ SENSOR_DESCRIPTIONS: tuple[NtfySensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
- suggested_display_precision=0,
+ suggested_display_precision=2,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
diff --git a/homeassistant/components/ntfy/services.yaml b/homeassistant/components/ntfy/services.yaml
new file mode 100644
index 00000000000..2c8e00746e5
--- /dev/null
+++ b/homeassistant/components/ntfy/services.yaml
@@ -0,0 +1,90 @@
+publish:
+ target:
+ entity:
+ domain: notify
+ integration: ntfy
+ fields:
+ title:
+ required: false
+ selector:
+ text:
+ example: Hello
+ message:
+ required: false
+ selector:
+ text:
+ multiline: true
+ example: World
+ markdown:
+ required: false
+ selector:
+ constant:
+ value: true
+ label: ""
+ example: true
+ tags:
+ required: false
+ selector:
+ text:
+ multiple: true
+ example: '["partying_face", "grin"]'
+ priority:
+ required: false
+ selector:
+ select:
+ options:
+ - value: "5"
+ label: "max"
+ - value: "4"
+ label: "high"
+ - value: "3"
+ label: "default"
+ - value: "2"
+ label: "low"
+ - value: "1"
+ label: "min"
+ mode: dropdown
+ translation_key: "priority"
+ sort: false
+ example: "5"
+ click:
+ required: false
+ selector:
+ text:
+ type: url
+ autocomplete: url
+ example: https://example.org
+ delay:
+ required: false
+ selector:
+ duration:
+ enable_day: true
+ example: '{"seconds": 30}'
+ attach:
+ required: false
+ selector:
+ text:
+ type: url
+ autocomplete: url
+ example: https://example.org/download.zip
+ email:
+ required: false
+ selector:
+ text:
+ type: email
+ autocomplete: email
+ example: mail@example.org
+ call:
+ required: false
+ selector:
+ text:
+ type: tel
+ autocomplete: tel
+ example: "1234567890"
+ icon:
+ required: false
+ selector:
+ text:
+ type: url
+ autocomplete: url
+ example: https://example.org/logo.png
diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json
index 08a0a20a30a..86d59e0dc6c 100644
--- a/homeassistant/components/ntfy/strings.json
+++ b/homeassistant/components/ntfy/strings.json
@@ -102,6 +102,40 @@
"data_description": {
"topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.",
"name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily."
+ },
+ "sections": {
+ "filter": {
+ "data": {
+ "filter_priority": "Filter by priority",
+ "filter_tags": "Filter by tags",
+ "filter_title": "Filter by title",
+ "filter_message": "Filter by message content"
+ },
+ "data_description": {
+ "filter_priority": "Include messages that match any of the selected priority levels. If no priority is selected, all messages are included by default",
+ "filter_tags": "Only include messages that have all selected tags",
+ "filter_title": "Include messages with a title that exactly matches the specified text",
+ "filter_message": "Include messages with content that exactly matches the specified text"
+ },
+ "name": "Message filters (optional)",
+ "description": "Apply filters to narrow down the messages received when Home Assistant subscribes to the topic. Filters apply only to the event entity."
+ }
+ }
+ },
+ "reconfigure": {
+ "title": "Message filters for {topic}",
+ "description": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::description%]",
+ "data": {
+ "filter_priority": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data::filter_priority%]",
+ "filter_tags": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data::filter_tags%]",
+ "filter_title": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data::filter_title%]",
+ "filter_message": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data::filter_message%]"
+ },
+ "data_description": {
+ "filter_priority": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data_description::filter_priority%]",
+ "filter_tags": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data_description::filter_tags%]",
+ "filter_title": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data_description::filter_title%]",
+ "filter_message": "[%key:component::ntfy::config_subentries::topic::step::add_topic::sections::filter::data_description::filter_message%]"
}
}
},
@@ -116,11 +150,34 @@
"invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed."
},
"abort": {
- "already_configured": "Topic is already configured"
+ "already_configured": "Topic is already configured",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
},
"entity": {
+ "event": {
+ "subscribe": {
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "triggered": "Triggered"
+ }
+ },
+ "time": { "name": "Time" },
+ "expires": { "name": "Expires" },
+ "topic": { "name": "[%key:component::ntfy::common::topic%]" },
+ "message": { "name": "Message" },
+ "title": { "name": "Title" },
+ "tags": { "name": "Tags" },
+ "priority": { "name": "Priority" },
+ "click": { "name": "Click" },
+ "icon": { "name": "Icon" },
+ "actions": { "name": "Actions" },
+ "attachment": { "name": "Attachment" }
+ }
+ }
+ },
"sensor": {
"messages": {
"name": "Messages published",
@@ -221,6 +278,94 @@
},
"timeout_error": {
"message": "Failed to connect to ntfy service due to a connection timeout"
+ },
+ "entity_not_found": {
+ "message": "The selected ntfy entity could not be found."
+ },
+ "entry_not_loaded": {
+ "message": "The selected ntfy service is currently not loaded or disabled in Home Assistant."
+ },
+ "delay_no_email": {
+ "message": "Delayed email notifications are not supported"
+ },
+ "delay_no_call": {
+ "message": "Delayed call notifications are not supported"
+ }
+ },
+ "services": {
+ "publish": {
+ "name": "Publish notification",
+ "description": "Publishes a notification message to a ntfy topic",
+ "fields": {
+ "title": {
+ "name": "[%key:component::notify::services::send_message::fields::title::name%]",
+ "description": "[%key:component::notify::services::send_message::fields::title::description%]"
+ },
+ "message": {
+ "name": "[%key:component::notify::services::send_message::fields::message::name%]",
+ "description": "[%key:component::notify::services::send_message::fields::message::description%]"
+ },
+ "markdown": {
+ "name": "Format as Markdown",
+ "description": "Enable Markdown formatting for the message body. See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/."
+ },
+ "tags": {
+ "name": "Tags/Emojis",
+ "description": "Add tags or emojis to the notification. Emojis (using shortcodes like smile) will appear in the notification title or message. Other tags will be displayed below the notification content."
+ },
+ "priority": {
+ "name": "Message priority",
+ "description": "All messages have a priority that defines how urgently your phone notifies you, depending on the configured vibration patterns, notification sounds, and visibility in the notification drawer or pop-over."
+ },
+ "click": {
+ "name": "Click URL",
+ "description": "URL that is opened when notification is clicked."
+ },
+ "delay": {
+ "name": "Delay delivery",
+ "description": "Set a delay for message delivery. Minimum delay is 10 seconds, maximum is 3 days."
+ },
+ "attach": {
+ "name": "Attachment URL",
+ "description": "Attach images or other files by URL."
+ },
+ "email": {
+ "name": "Forward to email",
+ "description": "Specify the address to forward the notification to, for example mail@example.com"
+ },
+ "call": {
+ "name": "Phone call",
+ "description": "Phone number to call and read the message out loud using text-to-speech. Requires ntfy Pro and prior phone number verification."
+ },
+ "icon": {
+ "name": "Icon URL",
+ "description": "Include an icon that will appear next to the text of the notification. Only JPEG and PNG images are supported."
+ }
+ }
+ }
+ },
+ "selector": {
+ "priority": {
+ "options": {
+ "1": "Minimum",
+ "2": "[%key:common::state::low%]",
+ "3": "Default",
+ "4": "[%key:common::state::high%]",
+ "5": "Maximum"
+ }
+ }
+ },
+ "issues": {
+ "topic_protected": {
+ "title": "Subscription failed: Topic {topic} is protected",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "Topic {topic} is protected",
+ "description": "The topic **{topic}** is protected and requires authentication to subscribe.\n\nTo resolve this issue, you have two options:\n\n1. **Reconfigure the ntfy integration**\nAdd a username and password that has permission to access this topic.\n\n2. **Deactivate the event entity**\nThis will stop Home Assistant from subscribing to the topic.\nClick **Submit** to deactivate the entity."
+ }
+ }
+ }
}
}
}
diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py
index 1ebd35711ac..b30c9425b0a 100644
--- a/homeassistant/components/number/__init__.py
+++ b/homeassistant/components/number/__init__.py
@@ -35,7 +35,6 @@ from .const import ( # noqa: F401
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
- ATTR_STEP_VALIDATION,
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
@@ -184,7 +183,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of a Number entity."""
_entity_component_unrecorded_attributes = frozenset(
- {ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_STEP_VALIDATION, ATTR_MODE}
+ {ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_MODE}
)
entity_description: NumberEntityDescription
@@ -372,7 +371,11 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@property
def __native_unit_of_measurement_compat(self) -> str | None:
- """Process ambiguous units."""
+ """Handle wrong character coding in unit provided by integrations.
+
+ NumberEntity should read the number's native unit through this property instead
+ of through native_unit_of_measurement.
+ """
native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get(
native_unit_of_measurement, native_unit_of_measurement
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index 93fbfac2ebb..fab3d6f4276 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -57,7 +57,6 @@ ATTR_VALUE = "value"
ATTR_MIN = "min"
ATTR_MAX = "max"
ATTR_STEP = "step"
-ATTR_STEP_VALIDATION = "step_validation"
DEFAULT_MIN_VALUE = 0.0
DEFAULT_MAX_VALUE = 100.0
@@ -89,7 +88,7 @@ class NumberDeviceClass(StrEnum):
APPARENT_POWER = "apparent_power"
"""Apparent power.
- Unit of measurement: `mVA`, `VA`
+ Unit of measurement: `mVA`, `VA`, `kVA`
"""
AQI = "aqi"
@@ -207,7 +206,7 @@ class NumberDeviceClass(StrEnum):
Unit of measurement:
- SI / metric: `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`
+ - USCS / imperial: `ft³`, `CCF`, `MCF`
"""
HUMIDITY = "humidity"
@@ -292,6 +291,12 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `μg/m³`
"""
+ PM4 = "pm4"
+ """Particulate matter <= 4 μm.
+
+ Unit of measurement: `μg/m³`
+ """
+
POWER_FACTOR = "power_factor"
"""Power factor.
@@ -328,6 +333,7 @@ class NumberDeviceClass(StrEnum):
- `Pa`, `hPa`, `kPa`
- `inHg`
- `psi`
+ - `inH₂O`
"""
REACTIVE_ENERGY = "reactive_energy"
@@ -398,7 +404,7 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `VOLUME_*` units
- SI / metric: `mL`, `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -410,7 +416,7 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `VOLUME_*` units
- SI / metric: `mL`, `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -427,7 +433,7 @@ class NumberDeviceClass(StrEnum):
Unit of measurement:
- SI / metric: `m³`, `L`
- - USCS / imperial: `ft³`, `CCF`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -493,6 +499,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
},
NumberDeviceClass.HUMIDITY: {PERCENTAGE},
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
@@ -506,6 +513,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ NumberDeviceClass.PM4: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
NumberDeviceClass.POWER: {
UnitOfPower.MILLIWATT,
@@ -546,6 +554,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.GALLONS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
},
NumberDeviceClass.WEIGHT: set(UnitOfMass),
NumberDeviceClass.WIND_DIRECTION: {DEGREE},
diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json
index 482b4bc6793..9d75e09a72d 100644
--- a/homeassistant/components/number/icons.json
+++ b/homeassistant/components/number/icons.json
@@ -147,6 +147,9 @@
"volume": {
"default": "mdi:car-coolant-level"
},
+ "volume_flow_rate": {
+ "default": "mdi:pipe-valve"
+ },
"volume_storage": {
"default": "mdi:storage-tank"
},
diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json
index 1e4290f1d75..8c94269f069 100644
--- a/homeassistant/components/number/strings.json
+++ b/homeassistant/components/number/strings.json
@@ -112,6 +112,9 @@
"pm1": {
"name": "[%key:component::sensor::entity_component::pm1::name%]"
},
+ "pm4": {
+ "name": "[%key:component::sensor::entity_component::pm4::name%]"
+ },
"pm10": {
"name": "[%key:component::sensor::entity_component::pm10::name%]"
},
diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json
index 8f993d5fbb1..560fc463fa6 100644
--- a/homeassistant/components/nut/strings.json
+++ b/homeassistant/components/nut/strings.json
@@ -295,7 +295,7 @@
"ups_realpower": { "name": "Real power" },
"ups_realpower_nominal": { "name": "Nominal real power" },
"ups_shutdown": { "name": "Shutdown ability" },
- "ups_start_auto": { "name": "Start on ac" },
+ "ups_start_auto": { "name": "Start on AC" },
"ups_start_battery": { "name": "Start on battery" },
"ups_start_reboot": { "name": "Reboot on battery" },
"ups_status": { "name": "Status data" },
diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py
index 864b03e9a7c..d9e009ed1f1 100644
--- a/homeassistant/components/ohme/coordinator.py
+++ b/homeassistant/components/ohme/coordinator.py
@@ -10,7 +10,7 @@ import logging
from ohme import ApiException, OhmeApiClient
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -83,6 +83,21 @@ class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
coordinator_name = "Advanced Settings"
+ def __init__(
+ self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
+ ) -> None:
+ """Initialise coordinator."""
+ super().__init__(hass, config_entry, client)
+
+ @callback
+ def _dummy_listener() -> None:
+ pass
+
+ # This coordinator is used by the API library to determine whether the
+ # charger is online and available. It is therefore required even if no
+ # entities are using it.
+ self.async_add_listener(_dummy_listener)
+
async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()
diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json
index 786c615d68a..14612fff6eb 100644
--- a/homeassistant/components/ohme/manifest.json
+++ b/homeassistant/components/ohme/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
- "requirements": ["ohme==1.5.1"]
+ "requirements": ["ohme==1.5.2"]
}
diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py
index 091e58dbe7f..805724b82e3 100644
--- a/homeassistant/components/ollama/__init__.py
+++ b/homeassistant/components/ollama/__init__.py
@@ -145,9 +145,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
- # Device and entity registries don't update the disabled_by flag
- # when moving a device or entity from one config entry to another,
- # so we need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to DEVICE or USER instead,
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
@@ -162,9 +162,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if device is not None:
- # Device and entity registries don't update the disabled_by flag when
- # moving a device or entity from one config entry to another, so we
- # need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to USER instead,
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py
index 2581698e185..95ddcc402c0 100644
--- a/homeassistant/components/ollama/entity.py
+++ b/homeassistant/components/ollama/entity.py
@@ -95,6 +95,7 @@ def _convert_content(
return ollama.Message(
role=MessageRole.ASSISTANT.value,
content=chat_content.content,
+ thinking=chat_content.thinking_content,
tool_calls=[
ollama.Message.ToolCall(
function=ollama.Message.ToolCall.Function(
@@ -103,7 +104,8 @@ def _convert_content(
)
)
for tool_call in chat_content.tool_calls or ()
- ],
+ ]
+ or None,
)
if isinstance(chat_content, conversation.UserContent):
images: list[ollama.Image] = []
@@ -162,6 +164,8 @@ async def _transform_stream(
]
if (content := response_message.get("content")) is not None:
chunk["content"] = content
+ if (thinking := response_message.get("thinking")) is not None:
+ chunk["thinking_content"] = thinking
if response_message.get("done"):
new_msg = True
yield chunk
diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py
index dfb592c8d45..c02fbdfa01d 100644
--- a/homeassistant/components/onedrive/backup.py
+++ b/homeassistant/components/onedrive/backup.py
@@ -35,7 +35,8 @@ from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
-UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
+MAX_CHUNK_SIZE = 60 * 1024 * 1024 # largest chunk possible, must be <= 60 MiB
+TARGET_CHUNKS = 20
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
METADATA_VERSION = 2
CACHE_TTL = 300
@@ -161,9 +162,22 @@ class OneDriveBackupAgent(BackupAgent):
self._folder_id,
await open_stream(),
)
+
+ # determine chunk based on target chunks
+ upload_chunk_size = backup.size / TARGET_CHUNKS
+ # find the nearest multiple of 320KB
+ upload_chunk_size = round(upload_chunk_size / (320 * 1024)) * (320 * 1024)
+ # limit to max chunk size
+ upload_chunk_size = min(upload_chunk_size, MAX_CHUNK_SIZE)
+ # ensure minimum chunk size of 320KB
+ upload_chunk_size = max(upload_chunk_size, 320 * 1024)
+
try:
backup_file = await LargeFileUploadClient.upload(
- self._token_function, file, session=async_get_clientsession(self._hass)
+ self._token_function,
+ file,
+ upload_chunk_size=upload_chunk_size,
+ session=async_get_clientsession(self._hass),
)
except HashMismatchError as err:
raise BackupAgentError(
diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py
index a4d1ec8f175..adbcb605b6c 100644
--- a/homeassistant/components/onkyo/__init__.py
+++ b/homeassistant/components/onkyo/__init__.py
@@ -52,10 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
try:
info = await async_interview(host)
+ except TimeoutError as exc:
+ raise ConfigEntryNotReady(f"Timed out interviewing: {host}") from exc
except OSError as exc:
- raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc
- if info is None:
- raise ConfigEntryNotReady(f"Unable to connect to: {host}")
+ raise ConfigEntryNotReady(f"Unexpected exception interviewing: {host}") from exc
manager = ReceiverManager(hass, entry, info)
diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py
index 75b0f92043d..f317eafec09 100644
--- a/homeassistant/components/onkyo/config_flow.py
+++ b/homeassistant/components/onkyo/config_flow.py
@@ -109,24 +109,22 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Config flow manual: %s", host)
try:
info = await async_interview(host)
+ except TimeoutError:
+ _LOGGER.warning("Timed out interviewing: %s", host)
+ errors["base"] = "cannot_connect"
except OSError:
- _LOGGER.exception("Unexpected exception")
+ _LOGGER.exception("Unexpected exception interviewing: %s", host)
errors["base"] = "unknown"
else:
- if info is None:
- errors["base"] = "cannot_connect"
+ self._receiver_info = info
+
+ await self.async_set_unique_id(info.identifier, raise_on_progress=False)
+ if self.source == SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_mismatch()
else:
- self._receiver_info = info
+ self._abort_if_unique_id_configured()
- await self.async_set_unique_id(
- info.identifier, raise_on_progress=False
- )
- if self.source == SOURCE_RECONFIGURE:
- self._abort_if_unique_id_mismatch()
- else:
- self._abort_if_unique_id_configured()
-
- return await self.async_step_configure_receiver()
+ return await self.async_step_configure_receiver()
suggested_values = user_input
if suggested_values is None and self.source == SOURCE_RECONFIGURE:
@@ -168,7 +166,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_infos = {}
discovered_names = {}
- current_unique_ids = self._async_current_ids()
+ current_unique_ids = self._async_current_ids(include_ignore=False)
for info in infos:
if info.identifier in current_unique_ids:
continue
@@ -214,13 +212,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await async_interview(host)
- except OSError:
- _LOGGER.exception("Unexpected exception interviewing host %s", host)
- return self.async_abort(reason="unknown")
-
- if info is None:
- _LOGGER.debug("SSDP eiscp is None: %s", host)
+ except TimeoutError:
+ _LOGGER.warning("Timed out interviewing: %s", host)
return self.async_abort(reason="cannot_connect")
+ except OSError:
+ _LOGGER.exception("Unexpected exception interviewing: %s", host)
+ return self.async_abort(reason="unknown")
await self.async_set_unique_id(info.identifier)
self._abort_if_unique_id_configured(updates={CONF_HOST: info.host})
diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py
index 05374bfe6cf..1b85e3627a2 100644
--- a/homeassistant/components/onkyo/media_player.py
+++ b/homeassistant/components/onkyo/media_player.py
@@ -341,12 +341,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
def process_update(self, message: status.Known) -> None:
"""Process update."""
match message:
- case status.Power(status.Power.Param.ON):
+ case status.Power(param=status.Power.Param.ON):
self._attr_state = MediaPlayerState.ON
- case status.Power(status.Power.Param.STANDBY):
+ case status.Power(param=status.Power.Param.STANDBY):
self._attr_state = MediaPlayerState.OFF
- case status.Volume(volume):
+ case status.Volume(param=volume):
if not self._supports_volume:
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
self._supports_volume = True
@@ -356,10 +356,10 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
)
self._attr_volume_level = min(1, volume_level)
- case status.Muting(muting):
+ case status.Muting(param=muting):
self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON)
- case status.InputSource(source):
+ case status.InputSource(param=source):
if source in self._source_mapping:
self._attr_source = self._source_mapping[source]
else:
@@ -373,7 +373,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._query_av_info_delayed()
- case status.ListeningMode(sound_mode):
+ case status.ListeningMode(param=sound_mode):
if not self._supports_sound_mode:
self._attr_supported_features |= (
MediaPlayerEntityFeature.SELECT_SOUND_MODE
@@ -393,13 +393,13 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._query_av_info_delayed()
- case status.HDMIOutput(hdmi_output):
+ case status.HDMIOutput(param=hdmi_output):
self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = (
self._hdmi_output_mapping[hdmi_output]
)
self._query_av_info_delayed()
- case status.TunerPreset(preset):
+ case status.TunerPreset(param=preset):
self._attr_extra_state_attributes[ATTR_PRESET] = preset
case status.AudioInformation():
@@ -427,11 +427,11 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
case status.FLDisplay():
self._query_av_info_delayed()
- case status.NotAvailable(Kind.AUDIO_INFORMATION):
+ case status.NotAvailable(kind=Kind.AUDIO_INFORMATION):
# Not available right now, but still supported
self._supports_audio_info = True
- case status.NotAvailable(Kind.VIDEO_INFORMATION):
+ case status.NotAvailable(kind=Kind.VIDEO_INFORMATION):
# Not available right now, but still supported
self._supports_video_info = True
diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py
index e4fe8bc6630..8fc5c5e7e0d 100644
--- a/homeassistant/components/onkyo/receiver.py
+++ b/homeassistant/components/onkyo/receiver.py
@@ -124,13 +124,10 @@ class ReceiverManager:
self.callbacks.clear()
-async def async_interview(host: str) -> ReceiverInfo | None:
+async def async_interview(host: str) -> ReceiverInfo:
"""Interview the receiver."""
- info: ReceiverInfo | None = None
- with contextlib.suppress(asyncio.TimeoutError):
- async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
- info = await aioonkyo.interview(host)
- return info
+ async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
+ return await aioonkyo.interview(host)
async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]:
diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py
index d29f732ef67..7fb27cc7b80 100644
--- a/homeassistant/components/onvif/binary_sensor.py
+++ b/homeassistant/components/onvif/binary_sensor.py
@@ -74,7 +74,7 @@ class ONVIFBinarySensor(ONVIFBaseEntity, RestoreEntity, BinarySensorEntity):
BinarySensorDeviceClass, entry.original_device_class
)
self._attr_entity_category = entry.entity_category
- self._attr_name = entry.name
+ self._attr_name = entry.name or entry.original_name
else:
event = device.events.get_uid(uid)
assert event
diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py
index a0162a05f76..f6387de009c 100644
--- a/homeassistant/components/onvif/sensor.py
+++ b/homeassistant/components/onvif/sensor.py
@@ -70,7 +70,7 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor):
SensorDeviceClass, entry.original_device_class
)
self._attr_entity_category = entry.entity_category
- self._attr_name = entry.name
+ self._attr_name = entry.name or entry.original_name
self._attr_native_unit_of_measurement = entry.unit_of_measurement
else:
event = device.events.get_uid(uid)
diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json
index 7988c50b1ac..b9b9150a887 100644
--- a/homeassistant/components/onvif/strings.json
+++ b/homeassistant/components/onvif/strings.json
@@ -4,7 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "no_h264": "There were no H264 streams available. Check the profile configuration on your device.",
+ "no_h264": "There were no H.264 streams available. Check the profile configuration on your device.",
"no_mac": "Could not configure unique ID for ONVIF device.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
@@ -43,7 +43,7 @@
},
"configure_profile": {
"description": "Create camera entity for {profile} at {resolution} resolution?",
- "title": "Configure Profiles",
+ "title": "Configure profiles",
"data": {
"include": "Create camera entity"
}
diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py
index f50563b59ea..b4c9a16693a 100644
--- a/homeassistant/components/openai_conversation/__init__.py
+++ b/homeassistant/components/openai_conversation/__init__.py
@@ -148,7 +148,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
content.extend(
await async_prepare_files_for_prompt(
- hass, [Path(filename) for filename in filenames]
+ hass, [(Path(filename), None) for filename in filenames]
)
)
@@ -320,9 +320,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
- # Device and entity registries don't update the disabled_by flag
- # when moving a device or entity from one config entry to another,
- # so we need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to DEVICE or USER instead,
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
@@ -337,9 +337,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if device is not None:
- # Device and entity registries don't update the disabled_by flag when
- # moving a device or entity from one config entry to another, so we
- # need to do it manually.
+ # Device and entity registries will set the disabled_by flag to None
+ # when moving a device or entity disabled by CONFIG_ENTRY to an enabled
+ # config entry, but we want to set it to USER instead,
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py
index 5fc700a73ad..bc05671e48f 100644
--- a/homeassistant/components/openai_conversation/ai_task.py
+++ b/homeassistant/components/openai_conversation/ai_task.py
@@ -2,8 +2,12 @@
from __future__ import annotations
+import base64
from json import JSONDecodeError
import logging
+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
@@ -12,8 +16,14 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
+from .const import CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL, UNSUPPORTED_IMAGE_MODELS
from .entity import OpenAIBaseLLMEntity
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigSubentry
+
+ from . import OpenAIConfigEntry
+
_LOGGER = logging.getLogger(__name__)
@@ -39,10 +49,16 @@ class OpenAITaskEntity(
):
"""OpenAI AI Task entity."""
- _attr_supported_features = (
- ai_task.AITaskEntityFeature.GENERATE_DATA
- | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
- )
+ def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
+ """Initialize the entity."""
+ super().__init__(entry, subentry)
+ self._attr_supported_features = (
+ ai_task.AITaskEntityFeature.GENERATE_DATA
+ | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
+ )
+ model = self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+ if not model.startswith(tuple(UNSUPPORTED_IMAGE_MODELS)):
+ self._attr_supported_features |= ai_task.AITaskEntityFeature.GENERATE_IMAGE
async def _async_generate_data(
self,
@@ -78,3 +94,56 @@ class OpenAITaskEntity(
conversation_id=chat_log.conversation_id,
data=data,
)
+
+ async def _async_generate_image(
+ self,
+ task: ai_task.GenImageTask,
+ chat_log: conversation.ChatLog,
+ ) -> ai_task.GenImageTaskResult:
+ """Handle a generate image task."""
+ await self._async_handle_chat_log(chat_log, task.name, force_image=True)
+
+ if not isinstance(chat_log.content[-1], conversation.AssistantContent):
+ raise HomeAssistantError(
+ "Last content in chat log is not an AssistantContent"
+ )
+
+ image_call: ImageGenerationCall | None = None
+ for content in reversed(chat_log.content):
+ if not isinstance(content, conversation.AssistantContent):
+ break
+ if isinstance(content.native, ImageGenerationCall):
+ if image_call is None or image_call.result is None:
+ image_call = content.native
+ else: # Remove image data from chat log to save memory
+ content.native.result = None
+
+ if image_call is None or image_call.result is None:
+ raise HomeAssistantError("No image returned")
+
+ image_data = base64.b64decode(image_call.result)
+ image_call.result = None
+
+ if hasattr(image_call, "output_format") and (
+ output_format := image_call.output_format
+ ):
+ mime_type = f"image/{output_format}"
+ else:
+ mime_type = "image/png"
+
+ if hasattr(image_call, "size") and (size := image_call.size):
+ width, height = tuple(size.split("x"))
+ else:
+ width, height = None, None
+
+ return ai_task.GenImageTaskResult(
+ image_data=image_data,
+ conversation_id=chat_log.conversation_id,
+ mime_type=mime_type,
+ width=int(width) if width else None,
+ height=int(height) if height else None,
+ model="gpt-image-1",
+ revised_prompt=image_call.revised_prompt
+ if hasattr(image_call, "revised_prompt")
+ else None,
+ )
diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py
index 2fd18913207..fda862e1dbe 100644
--- a/homeassistant/components/openai_conversation/const.py
+++ b/homeassistant/components/openai_conversation/const.py
@@ -60,6 +60,15 @@ UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [
"o3-mini",
]
+UNSUPPORTED_IMAGE_MODELS: list[str] = [
+ "gpt-5",
+ "o3-mini",
+ "o4",
+ "o1",
+ "gpt-3.5",
+ "gpt-4-turbo",
+]
+
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py
index 44d833c8e71..4d2c62a7a8c 100644
--- a/homeassistant/components/openai_conversation/entity.py
+++ b/homeassistant/components/openai_conversation/entity.py
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator, Callable, Iterable
import json
from mimetypes import guess_file_type
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any, Literal, cast
import openai
from openai._streaming import AsyncStream
@@ -37,14 +37,20 @@ from openai.types.responses import (
ResponseReasoningSummaryTextDeltaEvent,
ResponseStreamEvent,
ResponseTextDeltaEvent,
+ ToolChoiceTypesParam,
ToolParam,
WebSearchToolParam,
)
from openai.types.responses.response_create_params import ResponseCreateParamsStreaming
-from openai.types.responses.response_input_param import FunctionCallOutput
+from openai.types.responses.response_input_param import (
+ FunctionCallOutput,
+ ImageGenerationCall as ImageGenerationCallParam,
+)
+from openai.types.responses.response_output_item import ImageGenerationCall
from openai.types.responses.tool_param import (
CodeInterpreter,
CodeInterpreterContainerCodeInterpreterToolAuto,
+ ImageGeneration,
)
from openai.types.responses.web_search_tool_param import UserLocation
import voluptuous as vol
@@ -54,7 +60,7 @@ from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import device_registry as dr, llm
+from homeassistant.helpers import device_registry as dr, issue_registry as ir, llm
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
@@ -217,24 +223,30 @@ def _convert_content_to_param(
ResponseReasoningItemParam(
type="reasoning",
id=content.native.id,
- summary=[
- {
- "type": "summary_text",
- "text": summary,
- }
- for summary in reasoning_summary
- ]
- if content.thinking_content
- else [],
+ summary=(
+ [
+ {
+ "type": "summary_text",
+ "text": summary,
+ }
+ for summary in reasoning_summary
+ ]
+ if content.thinking_content
+ else []
+ ),
encrypted_content=content.native.encrypted_content,
)
)
reasoning_summary = []
+ elif isinstance(content.native, ImageGenerationCall):
+ messages.append(
+ cast(ImageGenerationCallParam, content.native.to_dict())
+ )
return messages
-async def _transform_stream(
+async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[
@@ -298,9 +310,11 @@ async def _transform_stream(
"tool_call_id": event.item.id,
"tool_name": "code_interpreter",
"tool_result": {
- "output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc]
- if event.item.outputs is not None
- else None
+ "output": (
+ [output.to_dict() for output in event.item.outputs] # type: ignore[misc]
+ if event.item.outputs is not None
+ else None
+ )
},
}
last_role = "tool_result"
@@ -324,6 +338,9 @@ async def _transform_stream(
"tool_result": {"status": event.item.status},
}
last_role = "tool_result"
+ elif isinstance(event.item, ImageGenerationCall):
+ yield {"native": event.item}
+ last_summary_index = -1 # Trigger new assistant message on next turn
elif isinstance(event, ResponseTextDeltaEvent):
yield {"content": event.delta}
elif isinstance(event, ResponseReasoningSummaryTextDeltaEvent):
@@ -429,6 +446,7 @@ class OpenAIBaseLLMEntity(Entity):
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
+ force_image: bool = False,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -495,6 +513,17 @@ class OpenAIBaseLLMEntity(Entity):
)
model_args.setdefault("include", []).append("code_interpreter_call.outputs") # type: ignore[union-attr]
+ if force_image:
+ tools.append(
+ ImageGeneration(
+ type="image_generation",
+ input_fidelity="high",
+ output_format="png",
+ )
+ )
+ model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation")
+ model_args["store"] = True # Avoid sending image data back and forth
+
if tools:
model_args["tools"] = tools
@@ -504,7 +533,7 @@ class OpenAIBaseLLMEntity(Entity):
if last_content.role == "user" and last_content.attachments:
files = await async_prepare_files_for_prompt(
self.hass,
- [a.path for a in last_content.attachments],
+ [(a.path, a.mime_type) for a in last_content.attachments],
)
last_message = messages[-1]
assert (
@@ -553,6 +582,20 @@ class OpenAIBaseLLMEntity(Entity):
):
LOGGER.error("Insufficient funds for OpenAI: %s", err)
raise HomeAssistantError("Insufficient funds for OpenAI") from err
+ if "Verify Organization" in str(err):
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ "organization_verification_required",
+ is_fixable=False,
+ is_persistent=False,
+ learn_more_url="https://help.openai.com/en/articles/10910291-api-organization-verification",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="organization_verification_required",
+ translation_placeholders={
+ "platform_settings": "https://platform.openai.com/settings/organization/general"
+ },
+ )
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err
@@ -562,7 +605,7 @@ class OpenAIBaseLLMEntity(Entity):
async def async_prepare_files_for_prompt(
- hass: HomeAssistant, files: list[Path]
+ hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> ResponseInputMessageContentListParam:
"""Append files to a prompt.
@@ -572,11 +615,12 @@ async def async_prepare_files_for_prompt(
def append_files_to_content() -> ResponseInputMessageContentListParam:
content: ResponseInputMessageContentListParam = []
- for file_path in files:
+ for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
- mime_type, _ = guess_file_type(file_path)
+ if mime_type is None:
+ mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json
index 38ebe205bd3..a96efbf1ce8 100644
--- a/homeassistant/components/openai_conversation/manifest.json
+++ b/homeassistant/components/openai_conversation/manifest.json
@@ -2,7 +2,7 @@
"domain": "openai_conversation",
"name": "OpenAI",
"after_dependencies": ["assist_pipeline", "intent"],
- "codeowners": ["@balloob"],
+ "codeowners": [],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json
index 304ef8b6bdc..190e86e87b8 100644
--- a/homeassistant/components/openai_conversation/strings.json
+++ b/homeassistant/components/openai_conversation/strings.json
@@ -189,6 +189,12 @@
}
}
},
+ "issues": {
+ "organization_verification_required": {
+ "title": "Organization verification required",
+ "description": "Your organization must be verified to use this model. Please go to {platform_settings} and select Verify Organization. If you just verified, it can take up to 15 minutes for access to propagate."
+ }
+ },
"exceptions": {
"invalid_config_entry": {
"message": "Invalid config entry provided. Got {config_entry}"
diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py
index 19e63747e4b..6edb42427f3 100644
--- a/homeassistant/components/openuv/__init__.py
+++ b/homeassistant/components/openuv/__init__.py
@@ -30,7 +30,7 @@ from .const import (
DOMAIN,
LOGGER,
)
-from .coordinator import OpenUvCoordinator
+from .coordinator import OpenUvCoordinator, OpenUvProtectionWindowCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await client.uv_protection_window(low=low, high=high)
coordinators: dict[str, OpenUvCoordinator] = {
- coordinator_name: OpenUvCoordinator(
+ coordinator_name: coordinator_cls(
hass,
entry=entry,
name=coordinator_name,
@@ -62,9 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
longitude=client.longitude,
update_method=update_method,
)
- for coordinator_name, update_method in (
- (DATA_UV, client.uv_index),
- (DATA_PROTECTION_WINDOW, async_update_protection_data),
+ for coordinator_cls, coordinator_name, update_method in (
+ (OpenUvCoordinator, DATA_UV, client.uv_index),
+ (
+ OpenUvProtectionWindowCoordinator,
+ DATA_PROTECTION_WINDOW,
+ async_update_protection_data,
+ ),
)
}
diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py
index 09c9ab75192..8165c66e7dd 100644
--- a/homeassistant/components/openuv/binary_sensor.py
+++ b/homeassistant/components/openuv/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.util.dt import as_local, parse_datetime, utcnow
+from homeassistant.util.dt import as_local
from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW
from .coordinator import OpenUvCoordinator
@@ -55,30 +55,27 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity):
def _update_attrs(self) -> None:
data = self.coordinator.data
- for key in ("from_time", "to_time", "from_uv", "to_uv"):
- if not data.get(key):
- LOGGER.warning("Skipping update due to missing data: %s", key)
- return
+ if not data:
+ LOGGER.warning("Skipping update due to missing data")
+ return
if self.entity_description.key == TYPE_PROTECTION_WINDOW:
- from_dt = parse_datetime(data["from_time"])
- to_dt = parse_datetime(data["to_time"])
-
- if not from_dt or not to_dt:
- LOGGER.warning(
- "Unable to parse protection window datetimes: %s, %s",
- data["from_time"],
- data["to_time"],
- )
- self._attr_is_on = False
- return
-
- self._attr_is_on = from_dt <= utcnow() <= to_dt
+ self._attr_is_on = data.get("is_on", False)
self._attr_extra_state_attributes.update(
{
- ATTR_PROTECTION_WINDOW_ENDING_TIME: as_local(to_dt),
- ATTR_PROTECTION_WINDOW_ENDING_UV: data["to_uv"],
- ATTR_PROTECTION_WINDOW_STARTING_UV: data["from_uv"],
- ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt),
+ attr_key: data[data_key]
+ for attr_key, data_key in (
+ (ATTR_PROTECTION_WINDOW_STARTING_UV, "from_uv"),
+ (ATTR_PROTECTION_WINDOW_ENDING_UV, "to_uv"),
+ )
+ }
+ )
+ self._attr_extra_state_attributes.update(
+ {
+ attr_key: as_local(data[data_key])
+ for attr_key, data_key in (
+ (ATTR_PROTECTION_WINDOW_STARTING_TIME, "from_time"),
+ (ATTR_PROTECTION_WINDOW_ENDING_TIME, "to_time"),
+ )
}
)
diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py
index cc09161b3e9..eb5970edef5 100644
--- a/homeassistant/components/openuv/coordinator.py
+++ b/homeassistant/components/openuv/coordinator.py
@@ -3,15 +3,18 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
+import datetime as dt
from typing import Any, cast
from pyopenuv.errors import InvalidApiKeyError, OpenUvError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import event as evt
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.dt import parse_datetime, utcnow
from .const import LOGGER
@@ -62,3 +65,90 @@ class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed(str(err)) from err
return cast(dict[str, Any], data["result"])
+
+
+class OpenUvProtectionWindowCoordinator(OpenUvCoordinator):
+ """Define an OpenUV data coordinator for the protetction window."""
+
+ _reprocess_listener: CALLBACK_TYPE | None = None
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ data = await super()._async_update_data()
+
+ for key in ("from_time", "to_time", "from_uv", "to_uv"):
+ if not data.get(key):
+ msg = "Skipping update due to missing data: {key}"
+ raise UpdateFailed(msg)
+
+ data = self._parse_data(data)
+ data = self._process_data(data)
+
+ self._schedule_reprocessing(data)
+
+ return data
+
+ def _parse_data(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Parse & update datetime values in data."""
+
+ from_dt = parse_datetime(data["from_time"])
+ to_dt = parse_datetime(data["to_time"])
+
+ if not from_dt or not to_dt:
+ LOGGER.warning(
+ "Unable to parse protection window datetimes: %s, %s",
+ data["from_time"],
+ data["to_time"],
+ )
+ return {}
+
+ return {**data, "from_time": from_dt, "to_time": to_dt}
+
+ def _process_data(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Process data for consumption by entities.
+
+ Adds the `is_on` key to the resulting data.
+ """
+ if not {"from_time", "to_time"}.issubset(data):
+ return {}
+
+ return {**data, "is_on": data["from_time"] <= utcnow() <= data["to_time"]}
+
+ def _schedule_reprocessing(self, data: dict[str, Any]) -> None:
+ """Schedule reprocessing of data."""
+
+ if not {"from_time", "to_time"}.issubset(data):
+ return
+
+ now = utcnow()
+ from_dt = data["from_time"]
+ to_dt = data["to_time"]
+ reprocess_at: dt.datetime | None = None
+
+ if from_dt and from_dt > now:
+ reprocess_at = from_dt
+ if to_dt and to_dt > now:
+ reprocess_at = to_dt if not reprocess_at else min(to_dt, reprocess_at)
+
+ if reprocess_at:
+ self._async_cancel_reprocess_listener()
+ self._reprocess_listener = evt.async_track_point_in_utc_time(
+ self.hass,
+ self._async_handle_reprocess_event,
+ reprocess_at,
+ )
+
+ def _async_cancel_reprocess_listener(self) -> None:
+ """Cancel the reprocess event listener."""
+ if self._reprocess_listener:
+ self._reprocess_listener()
+ self._reprocess_listener = None
+
+ @callback
+ def _async_handle_reprocess_event(self, now: dt.datetime) -> None:
+ """Timer callback for reprocessing the data & updating listeners."""
+ self._async_cancel_reprocess_listener()
+
+ self.data = self._process_data(self.data)
+ self._schedule_reprocessing(self.data)
+
+ self.async_update_listeners()
diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py
index 76a32af13b0..5805b602821 100644
--- a/homeassistant/components/openweathermap/config_flow.py
+++ b/homeassistant/components/openweathermap/config_flow.py
@@ -32,6 +32,24 @@ from .const import (
)
from .utils import build_data_and_options, validate_api_key
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES),
+ vol.Required(CONF_API_KEY): str,
+ vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
+ }
+)
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES),
+ vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
+ }
+)
+
class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for OpenWeatherMap."""
@@ -68,31 +86,21 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=user_input[CONF_NAME], data=data, options=options
)
+ schema_data = user_input
+ else:
+ schema_data = {
+ CONF_LATITUDE: self.hass.config.latitude,
+ CONF_LONGITUDE: self.hass.config.longitude,
+ CONF_LANGUAGE: self.hass.config.language,
+ }
description_placeholders["doc_url"] = (
"https://www.home-assistant.io/integrations/openweathermap/"
)
- schema = vol.Schema(
- {
- vol.Required(CONF_API_KEY): str,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
- vol.Optional(
- CONF_LATITUDE, default=self.hass.config.latitude
- ): cv.latitude,
- vol.Optional(
- CONF_LONGITUDE, default=self.hass.config.longitude
- ): cv.longitude,
- vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
- vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
- LANGUAGES
- ),
- }
- )
-
return self.async_show_form(
step_id="user",
- data_schema=schema,
+ data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, schema_data),
errors=errors,
description_placeholders=description_placeholders,
)
@@ -108,25 +116,7 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
return self.async_show_form(
step_id="init",
- data_schema=self._get_options_schema(),
- )
-
- def _get_options_schema(self):
- return vol.Schema(
- {
- vol.Optional(
- CONF_MODE,
- default=self.config_entry.options.get(
- CONF_MODE,
- self.config_entry.data.get(CONF_MODE, DEFAULT_OWM_MODE),
- ),
- ): vol.In(OWM_MODES),
- vol.Optional(
- CONF_LANGUAGE,
- default=self.config_entry.options.get(
- CONF_LANGUAGE,
- self.config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE),
- ),
- ): vol.In(LANGUAGES),
- }
+ data_schema=self.add_suggested_values_to_schema(
+ OPTIONS_SCHEMA, self.config_entry.options
+ ),
)
diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json
index 51de5cf2244..718ce3e6fdd 100644
--- a/homeassistant/components/openweathermap/strings.json
+++ b/homeassistant/components/openweathermap/strings.json
@@ -17,6 +17,14 @@
"mode": "[%key:common::config_flow::data::mode%]",
"name": "[%key:common::config_flow::data::name%]"
},
+ "data_description": {
+ "api_key": "API key for the OpenWeatherMap integration",
+ "language": "Language for the OpenWeatherMap content",
+ "latitude": "Latitude of the location",
+ "longitude": "Longitude of the location",
+ "mode": "Mode for the OpenWeatherMap API",
+ "name": "Name for this OpenWeatherMap location"
+ },
"description": "To generate an API key, please refer to the [integration documentation]({doc_url})"
}
}
@@ -27,6 +35,10 @@
"data": {
"language": "[%key:common::config_flow::data::language%]",
"mode": "[%key:common::config_flow::data::mode%]"
+ },
+ "data_description": {
+ "language": "[%key:component::openweathermap::config::step::user::data_description::language%]",
+ "mode": "[%key:component::openweathermap::config::step::user::data_description::mode%]"
}
}
}
diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py
index f182b083b90..56f44fa46fb 100644
--- a/homeassistant/components/openweathermap/weather.py
+++ b/homeassistant/components/openweathermap/weather.py
@@ -181,7 +181,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
@property
- def visibility(self) -> float | str | None:
+ def native_visibility(self) -> float | None:
"""Return visibility."""
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)
diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py
index 66f35a51b87..bc085dbfa4d 100644
--- a/homeassistant/components/opnsense/__init__.py
+++ b/homeassistant/components/opnsense/__init__.py
@@ -1,4 +1,4 @@
-"""Support for OPNSense Routers."""
+"""Support for OPNsense Routers."""
import logging
@@ -12,15 +12,16 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
+from .const import (
+ CONF_API_SECRET,
+ CONF_INTERFACE_CLIENT,
+ CONF_TRACKER_INTERFACES,
+ DOMAIN,
+ OPNSENSE_DATA,
+)
+
_LOGGER = logging.getLogger(__name__)
-CONF_API_SECRET = "api_secret"
-CONF_TRACKER_INTERFACE = "tracker_interfaces"
-
-DOMAIN = "opnsense"
-
-OPNSENSE_DATA = DOMAIN
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -29,7 +30,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_API_SECRET): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean,
- vol.Optional(CONF_TRACKER_INTERFACE, default=[]): vol.All(
+ vol.Optional(CONF_TRACKER_INTERFACES, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
}
@@ -47,7 +48,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
api_key = conf[CONF_API_KEY]
api_secret = conf[CONF_API_SECRET]
verify_ssl = conf[CONF_VERIFY_SSL]
- tracker_interfaces = conf[CONF_TRACKER_INTERFACE]
+ tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
interfaces_client = diagnostics.InterfaceClient(
api_key, api_secret, url, verify_ssl, timeout=20
@@ -72,8 +73,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return False
hass.data[OPNSENSE_DATA] = {
- "interfaces": interfaces_client,
- CONF_TRACKER_INTERFACE: tracker_interfaces,
+ CONF_INTERFACE_CLIENT: interfaces_client,
+ CONF_TRACKER_INTERFACES: tracker_interfaces,
}
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config)
diff --git a/homeassistant/components/opnsense/const.py b/homeassistant/components/opnsense/const.py
new file mode 100644
index 00000000000..62ab16701f4
--- /dev/null
+++ b/homeassistant/components/opnsense/const.py
@@ -0,0 +1,8 @@
+"""Constants for OPNsense component."""
+
+DOMAIN = "opnsense"
+OPNSENSE_DATA = DOMAIN
+
+CONF_API_SECRET = "api_secret"
+CONF_INTERFACE_CLIENT = "interface_client"
+CONF_TRACKER_INTERFACES = "tracker_interfaces"
diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py
index 6357ce38e1d..5f6d8d2d436 100644
--- a/homeassistant/components/opnsense/device_tracker.py
+++ b/homeassistant/components/opnsense/device_tracker.py
@@ -1,34 +1,41 @@
-"""Device tracker support for OPNSense routers."""
+"""Device tracker support for OPNsense routers."""
-from __future__ import annotations
+from typing import Any, NewType
+
+from pyopnsense import diagnostics
from homeassistant.components.device_tracker import DeviceScanner
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
-from . import CONF_TRACKER_INTERFACE, OPNSENSE_DATA
+from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA
+
+DeviceDetails = NewType("DeviceDetails", dict[str, Any])
+DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails])
async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
-) -> OPNSenseDeviceScanner:
- """Configure the OPNSense device_tracker."""
- interface_client = hass.data[OPNSENSE_DATA]["interfaces"]
- return OPNSenseDeviceScanner(
- interface_client, hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACE]
+) -> DeviceScanner | None:
+ """Configure the OPNsense device_tracker."""
+ return OPNsenseDeviceScanner(
+ hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT],
+ hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES],
)
-class OPNSenseDeviceScanner(DeviceScanner):
- """Class which queries a router running OPNsense."""
+class OPNsenseDeviceScanner(DeviceScanner):
+ """This class queries a router running OPNsense."""
- def __init__(self, client, interfaces):
+ def __init__(
+ self, client: diagnostics.InterfaceClient, interfaces: list[str]
+ ) -> None:
"""Initialize the scanner."""
- self.last_results = {}
+ self.last_results: dict[str, Any] = {}
self.client = client
self.interfaces = interfaces
- def _get_mac_addrs(self, devices):
+ def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict:
"""Create dict with mac address keys from list of devices."""
out_devices = {}
for device in devices:
@@ -36,30 +43,31 @@ class OPNSenseDeviceScanner(DeviceScanner):
out_devices[device["mac"]] = device
return out_devices
- def scan_devices(self):
+ def scan_devices(self) -> list[str]:
"""Scan for new devices and return a list with found device IDs."""
self.update_info()
return list(self.last_results)
- def get_device_name(self, device):
+ def get_device_name(self, device: str) -> str | None:
"""Return the name of the given device or None if we don't know."""
if device not in self.last_results:
return None
return self.last_results[device].get("hostname") or None
- def update_info(self):
- """Ensure the information from the OPNSense router is up to date.
+ def update_info(self) -> bool:
+ """Ensure the information from the OPNsense router is up to date.
Return boolean if scanning successful.
"""
-
devices = self.client.get_arp()
self.last_results = self._get_mac_addrs(devices)
+ return True
- def get_extra_attributes(self, device):
+ def get_extra_attributes(self, device: str) -> dict[Any, Any]:
"""Return the extra attrs of the given device."""
if device not in self.last_results:
- return None
- if not (mfg := self.last_results[device].get("manufacturer")):
+ return {}
+ mfg = self.last_results[device].get("manufacturer")
+ if not mfg:
return {}
return {"manufacturer": mfg}
diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json
index 4dd82216f1a..0a9aecbde25 100644
--- a/homeassistant/components/opnsense/manifest.json
+++ b/homeassistant/components/opnsense/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "opnsense",
- "name": "OPNSense",
+ "name": "OPNsense",
"codeowners": ["@mtreinish"],
"documentation": "https://www.home-assistant.io/integrations/opnsense",
"iot_class": "local_polling",
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index e127824ac19..5d95acaa779 100644
--- a/homeassistant/components/opower/manifest.json
+++ b/homeassistant/components/opower/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
- "requirements": ["opower==0.15.2"]
+ "quality_scale": "bronze",
+ "requirements": ["opower==0.15.6"]
}
diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml
new file mode 100644
index 00000000000..77b97763db5
--- /dev/null
+++ b/homeassistant/components/opower/quality_scale.yaml
@@ -0,0 +1,79 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not provide any service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not provide any service actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: The integration does not subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: The integration does not provide any service actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: The integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices:
+ status: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: The integration does not support discovery.
+ discovery:
+ status: exempt
+ comment: The integration does not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: No custom icons are defined; icons from device classes are sufficient.
+ reconfiguration-flow:
+ status: exempt
+ comment: The integration has no user-configurable options that are not authentication-related.
+ repair-issues: done
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json
new file mode 100644
index 00000000000..ec8426d28a0
--- /dev/null
+++ b/homeassistant/components/oralb/icons.json
@@ -0,0 +1,61 @@
+{
+ "entity": {
+ "sensor": {
+ "pressure": {
+ "default": "mdi:tooth-outline",
+ "state": {
+ "high": "mdi:tooth",
+ "low": "mdi:alert",
+ "power_button_pressed": "mdi:power",
+ "button_pressed": "mdi:radiobox-marked"
+ }
+ },
+ "sector": {
+ "default": "mdi:circle-outline",
+ "state": {
+ "sector_1": "mdi:circle-slice-2",
+ "sector_2": "mdi:circle-slice-4",
+ "sector_3": "mdi:circle-slice-6",
+ "sector_4": "mdi:circle-slice-8",
+ "success": "mdi:check-circle-outline"
+ }
+ },
+ "toothbrush_state": {
+ "default": "mdi:toothbrush-electric",
+ "state": {
+ "initializing": "mdi:sync",
+ "idle": "mdi:toothbrush-electric",
+ "running": "mdi:waveform",
+ "charging": "mdi:battery-charging",
+ "setup": "mdi:wrench",
+ "flight_menu": "mdi:airplane",
+ "selection_menu": "mdi:menu",
+ "off": "mdi:power",
+ "sleeping": "mdi:sleep",
+ "transport": "mdi:dolly"
+ }
+ },
+ "number_of_sectors": {
+ "default": "mdi:chart-pie"
+ },
+ "mode": {
+ "default": "mdi:toothbrush-paste",
+ "state": {
+ "daily_clean": "mdi:repeat-once",
+ "sensitive": "mdi:feather",
+ "gum_care": "mdi:tooth-outline",
+ "intense": "mdi:shape-circle-plus",
+ "whitening": "mdi:shimmer",
+ "whiten": "mdi:shimmer",
+ "tongue_cleaning": "mdi:gate-and",
+ "super_sensitive": "mdi:feather",
+ "massage": "mdi:spa",
+ "deep_clean": "mdi:water",
+ "turbo": "mdi:car-turbocharger",
+ "off": "mdi:power",
+ "settings": "mdi:cog-outline"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py
index 3b345f4b36a..17d68a6aaab 100644
--- a/homeassistant/components/oralb/sensor.py
+++ b/homeassistant/components/oralb/sensor.py
@@ -3,6 +3,13 @@
from __future__ import annotations
from oralb_ble import OralBSensor, SensorUpdate
+from oralb_ble.parser import (
+ IO_SERIES_MODES,
+ PRESSURE,
+ SECTOR_MAP,
+ SMART_SERIES_MODES,
+ STATES,
+)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
@@ -39,6 +46,8 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
key=OralBSensor.SECTOR,
translation_key="sector",
entity_category=EntityCategory.DIAGNOSTIC,
+ options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}],
+ device_class=SensorDeviceClass.ENUM,
),
OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription(
key=OralBSensor.NUMBER_OF_SECTORS,
@@ -53,16 +62,26 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
),
OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription(
key=OralBSensor.TOOTHBRUSH_STATE,
+ translation_key="toothbrush_state",
+ options=[v.replace(" ", "_") for v in set(STATES.values())],
+ device_class=SensorDeviceClass.ENUM,
name=None,
),
OralBSensor.PRESSURE: SensorEntityDescription(
key=OralBSensor.PRESSURE,
translation_key="pressure",
+ options=[v.replace(" ", "_") for v in set(PRESSURE.values()) | {"low"}],
+ device_class=SensorDeviceClass.ENUM,
),
OralBSensor.MODE: SensorEntityDescription(
key=OralBSensor.MODE,
translation_key="mode",
entity_category=EntityCategory.DIAGNOSTIC,
+ options=[
+ v.replace(" ", "_")
+ for v in set(IO_SERIES_MODES.values()) | set(SMART_SERIES_MODES.values())
+ ],
+ device_class=SensorDeviceClass.ENUM,
),
OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription(
key=OralBSensor.SIGNAL_STRENGTH,
@@ -134,7 +153,15 @@ class OralBBluetoothSensorEntity(
@property
def native_value(self) -> str | int | None:
"""Return the native value."""
- return self.processor.entity_data.get(self.entity_key)
+ value = self.processor.entity_data.get(self.entity_key)
+ if isinstance(value, str):
+ value = value.replace(" ", "_")
+ if (
+ self.entity_description.options is not None
+ and value not in self.entity_description.options
+ ): # append unknown values to enum
+ self.entity_description.options.append(value)
+ return value
@property
def available(self) -> bool:
diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json
index 775bbedac74..db3b8de5965 100644
--- a/homeassistant/components/oralb/strings.json
+++ b/homeassistant/components/oralb/strings.json
@@ -22,7 +22,15 @@
"entity": {
"sensor": {
"sector": {
- "name": "Sector"
+ "name": "Sector",
+ "state": {
+ "no_sector": "No sector",
+ "sector_1": "Sector 1",
+ "sector_2": "Sector 2",
+ "sector_3": "Sector 3",
+ "sector_4": "Sector 4",
+ "success": "Success"
+ }
},
"number_of_sectors": {
"name": "Number of sectors"
@@ -31,10 +39,48 @@
"name": "Sector timer"
},
"pressure": {
- "name": "Pressure"
+ "name": "Pressure",
+ "state": {
+ "normal": "[%key:common::state::normal%]",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "power_button_pressed": "Power button pressed",
+ "button_pressed": "Button pressed"
+ }
},
"mode": {
- "name": "Brushing mode"
+ "name": "Brushing mode",
+ "state": {
+ "daily_clean": "Daily clean",
+ "sensitive": "Sensitive",
+ "gum_care": "Gum care",
+ "intense": "Intense",
+ "whitening": "Whiten",
+ "whiten": "[%key:component::oralb::entity::sensor::mode::state::whitening%]",
+ "tongue_cleaning": "Tongue clean",
+ "super_sensitive": "Super sensitive",
+ "massage": "Massage",
+ "deep_clean": "Deep clean",
+ "turbo": "Turbo",
+ "off": "[%key:common::state::off%]",
+ "settings": "Settings"
+ }
+ },
+ "toothbrush_state": {
+ "state": {
+ "initializing": "Initializing",
+ "idle": "[%key:common::state::idle%]",
+ "running": "Running",
+ "charging": "[%key:common::state::charging%]",
+ "setup": "Setup",
+ "flight_menu": "Flight menu",
+ "selection_menu": "Selection menu",
+ "off": "[%key:common::state::off%]",
+ "sleeping": "Sleeping",
+ "transport": "Transport",
+ "final_test": "Final test",
+ "pcb_test": "PCB test"
+ }
}
}
}
diff --git a/homeassistant/components/osoenergy/services.yaml b/homeassistant/components/osoenergy/services.yaml
index 4cd91f3285f..a602598a70a 100644
--- a/homeassistant/components/osoenergy/services.yaml
+++ b/homeassistant/components/osoenergy/services.yaml
@@ -2,10 +2,12 @@ get_profile:
target:
entity:
domain: water_heater
+ integration: osoenergy
set_profile:
target:
entity:
domain: water_heater
+ integration: osoenergy
fields:
hour_00:
required: false
@@ -227,6 +229,7 @@ set_v40_min:
target:
entity:
domain: water_heater
+ integration: osoenergy
fields:
v40_min:
required: true
@@ -241,6 +244,7 @@ turn_away_mode_on:
target:
entity:
domain: water_heater
+ integration: osoenergy
fields:
duration_days:
required: true
@@ -255,6 +259,7 @@ turn_off:
target:
entity:
domain: water_heater
+ integration: osoenergy
fields:
until_temp_limit:
required: true
@@ -266,6 +271,7 @@ turn_on:
target:
entity:
domain: water_heater
+ integration: osoenergy
fields:
until_temp_limit:
required: true
diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py
index 514f6c7617c..ebdf5ddeace 100644
--- a/homeassistant/components/otbr/config_flow.py
+++ b/homeassistant/components/otbr/config_flow.py
@@ -72,10 +72,7 @@ async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str:
if _is_yellow(hass) and device == "/dev/ttyAMA1":
return f"Home Assistant Yellow ({discovery_info.name})"
- if device and "SkyConnect" in device:
- return f"Home Assistant SkyConnect ({discovery_info.name})"
-
- if device and "Connect_ZBT-1" in device:
+ if device and ("Connect_ZBT-1" in device or "SkyConnect" in device):
return f"Home Assistant Connect ZBT-1 ({discovery_info.name})"
return discovery_info.name
diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json
index f62f89cff40..f6adbb20427 100644
--- a/homeassistant/components/otp/manifest.json
+++ b/homeassistant/components/otp/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["pyotp"],
"quality_scale": "internal",
- "requirements": ["pyotp==2.8.0"]
+ "requirements": ["pyotp==2.9.0"]
}
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 041571f7b5f..709d93bb2b4 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
@@ -52,6 +52,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
OverkizCommandParam.OFF: HVACMode.OFF,
OverkizCommandParam.AUTO: HVACMode.AUTO,
OverkizCommandParam.BASIC: HVACMode.HEAT,
+ OverkizCommandParam.MANUAL: HVACMode.HEAT,
OverkizCommandParam.STANDBY: HVACMode.OFF,
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
OverkizCommandParam.INTERNAL: HVACMode.AUTO,
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index 7f5f4ad85bd..99b7d48dcca 100644
--- a/homeassistant/components/overkiz/const.py
+++ b/homeassistant/components/overkiz/const.py
@@ -100,6 +100,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
+ UIWidget.DISCRETE_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported)
UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported)
UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported)
diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json
index 335ae7ba4ef..b82b45de16c 100644
--- a/homeassistant/components/overkiz/strings.json
+++ b/homeassistant/components/overkiz/strings.json
@@ -21,7 +21,7 @@
}
},
"cloud": {
- "description": "Enter your application credentials.",
+ "description": "Enter your credentials.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py
index d14b2792947..9260f9800a1 100644
--- a/homeassistant/components/overkiz/switch.py
+++ b/homeassistant/components/overkiz/switch.py
@@ -100,6 +100,15 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [
),
entity_category=EntityCategory.CONFIG,
),
+ OverkizSwitchDescription(
+ key=UIWidget.DISCRETE_EXTERIOR_HEATING,
+ turn_on=OverkizCommand.ON,
+ turn_off=OverkizCommand.OFF,
+ icon="mdi:radiator",
+ is_on=lambda select_state: (
+ select_state(OverkizState.CORE_ON_OFF) == OverkizCommandParam.ON
+ ),
+ ),
]
SUPPORTED_DEVICES = {
diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json
index 0fc90808bc9..824b3305543 100644
--- a/homeassistant/components/ovo_energy/manifest.json
+++ b/homeassistant/components/ovo_energy/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["ovoenergy"],
- "requirements": ["ovoenergy==2.0.1"]
+ "requirements": ["ovoenergy==3.0.2"]
}
diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json
index 7ff5a143451..adbbd30b60d 100644
--- a/homeassistant/components/owntracks/manifest.json
+++ b/homeassistant/components/owntracks/manifest.json
@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/owntracks",
"iot_class": "local_push",
"loggers": ["nacl"],
- "requirements": ["PyNaCl==1.5.0"],
+ "requirements": ["PyNaCl==1.6.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py
index a7ede186d72..d562943698a 100644
--- a/homeassistant/components/p1_monitor/config_flow.py
+++ b/homeassistant/components/p1_monitor/config_flow.py
@@ -40,7 +40,7 @@ class P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN):
port=user_input[CONF_PORT],
session=session,
) as client:
- await client.smartmeter()
+ await client.settings()
except P1MonitorError:
errors["base"] = "cannot_connect"
else:
diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json
index 28016242a6a..686864606a9 100644
--- a/homeassistant/components/p1_monitor/manifest.json
+++ b/homeassistant/components/p1_monitor/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/p1_monitor",
"iot_class": "local_polling",
"loggers": ["p1monitor"],
- "requirements": ["p1monitor==3.1.0"]
+ "requirements": ["p1monitor==3.2.0"]
}
diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py
index 77564245522..92ee6d782ea 100644
--- a/homeassistant/components/pandora/media_player.py
+++ b/homeassistant/components/pandora/media_player.py
@@ -115,9 +115,7 @@ class PandoraMediaPlayer(MediaPlayerEntity):
async def _start_pianobar(self) -> bool:
pianobar = pexpect.spawn("pianobar", encoding="utf-8")
pianobar.delaybeforesend = None
- # mypy thinks delayafterread must be a float but that is not what pexpect says
- # https://github.com/pexpect/pexpect/blob/4.9/pexpect/expect.py#L170
- pianobar.delayafterread = None # type: ignore[assignment]
+ pianobar.delayafterread = None
pianobar.delayafterclose = 0
pianobar.delayafterterminate = 0
_LOGGER.debug("Started pianobar subprocess")
diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json
index 43c61185f3a..b2c80c5c18f 100644
--- a/homeassistant/components/paperless_ngx/manifest.json
+++ b/homeassistant/components/paperless_ngx/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pypaperless"],
- "quality_scale": "silver",
+ "quality_scale": "platinum",
"requirements": ["pypaperless==4.1.1"]
}
diff --git a/homeassistant/components/paperless_ngx/quality_scale.yaml b/homeassistant/components/paperless_ngx/quality_scale.yaml
index f0d3296da10..15f16f085d0 100644
--- a/homeassistant/components/paperless_ngx/quality_scale.yaml
+++ b/homeassistant/components/paperless_ngx/quality_scale.yaml
@@ -67,7 +67,10 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: exempt
comment: Service type integration
diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py
index 0dd8646b17e..46e9a121649 100644
--- a/homeassistant/components/person/__init__.py
+++ b/homeassistant/components/person/__init__.py
@@ -15,6 +15,7 @@ from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
SourceType,
)
+from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.const import (
ATTR_EDITABLE,
ATTR_GPS_ACCURACY,
@@ -464,7 +465,7 @@ class Person(
"""Register device trackers."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
- self._parse_source_state(state)
+ self._parse_source_state(state, state)
if self.hass.is_running:
# Update person now if hass is already running.
@@ -514,7 +515,7 @@ class Person(
@callback
def _update_state(self) -> None:
"""Update the state."""
- latest_non_gps_home = latest_not_home = latest_gps = latest = None
+ latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None
for entity_id in self._config[CONF_DEVICE_TRACKERS]:
state = self.hass.states.get(entity_id)
@@ -530,13 +531,23 @@ class Person(
if latest_non_gps_home:
latest = latest_non_gps_home
+ if (
+ latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None
+ and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None
+ and (home_zone := self.hass.states.get(ENTITY_ID_HOME))
+ ):
+ coordinates = home_zone
+ else:
+ coordinates = latest_non_gps_home
elif latest_gps:
latest = latest_gps
+ coordinates = latest_gps
else:
latest = latest_not_home
+ coordinates = latest_not_home
- if latest:
- self._parse_source_state(latest)
+ if latest and coordinates:
+ self._parse_source_state(latest, coordinates)
else:
self._attr_state = None
self._source = None
@@ -548,15 +559,15 @@ class Person(
self.async_write_ha_state()
@callback
- def _parse_source_state(self, state: State) -> None:
+ def _parse_source_state(self, state: State, coordinates: State) -> None:
"""Parse source state and set person attributes.
This is a device tracker state or the restored person state.
"""
self._attr_state = state.state
self._source = state.entity_id
- self._latitude = state.attributes.get(ATTR_LATITUDE)
- self._longitude = state.attributes.get(ATTR_LONGITUDE)
+ self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
+ self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
@callback
diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json
index 0c1792e9277..46ccf85db4a 100644
--- a/homeassistant/components/person/manifest.json
+++ b/homeassistant/components/person/manifest.json
@@ -2,7 +2,7 @@
"domain": "person",
"name": "Person",
"codeowners": [],
- "dependencies": ["image_upload", "http"],
+ "dependencies": ["image_upload", "http", "zone"],
"documentation": "https://www.home-assistant.io/integrations/person",
"integration_type": "system",
"iot_class": "calculated",
diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json
index 0e88d6d44a9..f478d5f3f3e 100644
--- a/homeassistant/components/philips_js/manifest.json
+++ b/homeassistant/components/philips_js/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/philips_js",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
- "requirements": ["ha-philipsjs==3.2.2"],
+ "requirements": ["ha-philipsjs==3.2.4"],
"zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."]
}
diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py
index ae51fe166c4..7d8dbc50866 100644
--- a/homeassistant/components/pi_hole/__init__.py
+++ b/homeassistant/components/pi_hole/__init__.py
@@ -129,10 +129,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo
raise ConfigEntryAuthFailed
except HoleError as err:
if str(err) == "Authentication failed: Invalid password":
- raise ConfigEntryAuthFailed from err
- raise UpdateFailed(f"Failed to communicate with API: {err}") from err
+ raise ConfigEntryAuthFailed(
+ f"Pi-hole {name} at host {host}, reported an invalid password"
+ ) from err
+ raise UpdateFailed(
+ f"Pi-hole {name} at host {host}, update failed with HoleError: {err}"
+ ) from err
if not isinstance(api.data, dict):
- raise ConfigEntryAuthFailed
+ raise ConfigEntryAuthFailed(
+ f"Pi-hole {name} at host {host}, returned an unexpected response: {api.data}, assuming authentication failed"
+ )
coordinator = DataUpdateCoordinator(
hass,
diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py
index c2399c61f93..91214ba9ebe 100644
--- a/homeassistant/components/playstation_network/__init__.py
+++ b/homeassistant/components/playstation_network/__init__.py
@@ -9,6 +9,7 @@ from .const import CONF_NPSSO
from .coordinator import (
PlaystationNetworkConfigEntry,
PlaystationNetworkFriendDataCoordinator,
+ PlaystationNetworkFriendlistCoordinator,
PlaystationNetworkGroupsUpdateCoordinator,
PlaystationNetworkRuntimeData,
PlaystationNetworkTrophyTitlesCoordinator,
@@ -40,6 +41,8 @@ async def async_setup_entry(
groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry)
await groups.async_config_entry_first_refresh()
+ friends_list = PlaystationNetworkFriendlistCoordinator(hass, psn, entry)
+
friends = {}
for subentry_id, subentry in entry.subentries.items():
@@ -50,7 +53,7 @@ async def async_setup_entry(
friends[subentry_id] = friend_coordinator
entry.runtime_data = PlaystationNetworkRuntimeData(
- coordinator, trophy_titles, groups, friends
+ coordinator, trophy_titles, groups, friends, friends_list
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py
index d7d82292378..72df14dd239 100644
--- a/homeassistant/components/playstation_network/config_flow.py
+++ b/homeassistant/components/playstation_network/config_flow.py
@@ -10,7 +10,6 @@ from psnawp_api.core.psnawp_exceptions import (
PSNAWPInvalidTokenError,
PSNAWPNotFoundError,
)
-from psnawp_api.models import User
from psnawp_api.utils.misc import parse_npsso_token
import voluptuous as vol
@@ -169,13 +168,12 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
class FriendSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding a friend."""
- friends_list: dict[str, User]
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Subentry user flow."""
config_entry: PlaystationNetworkConfigEntry = self._get_entry()
+ friends_list = config_entry.runtime_data.user_data.psn.friends_list
if user_input is not None:
config_entries = self.hass.config_entries.async_entries(DOMAIN)
@@ -190,19 +188,12 @@ class FriendSubentryFlowHandler(ConfigSubentryFlow):
return self.async_abort(reason="already_configured")
return self.async_create_entry(
- title=self.friends_list[user_input[CONF_ACCOUNT_ID]].online_id,
+ title=friends_list[user_input[CONF_ACCOUNT_ID]].online_id,
data={},
unique_id=user_input[CONF_ACCOUNT_ID],
)
- self.friends_list = await self.hass.async_add_executor_job(
- lambda: {
- friend.account_id: friend
- for friend in config_entry.runtime_data.user_data.psn.user.friends_list()
- }
- )
-
- if not self.friends_list:
+ if not friends_list:
return self.async_abort(reason="no_friends")
options = [
@@ -210,7 +201,7 @@ class FriendSubentryFlowHandler(ConfigSubentryFlow):
value=friend.account_id,
label=friend.online_id,
)
- for friend in self.friends_list.values()
+ for friend in friends_list.values()
]
return self.async_show_form(
diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py
index 977632de23b..2dced4b64ad 100644
--- a/homeassistant/components/playstation_network/coordinator.py
+++ b/homeassistant/components/playstation_network/coordinator.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from abc import abstractmethod
from dataclasses import dataclass
from datetime import timedelta
+import json
import logging
from typing import TYPE_CHECKING, Any
@@ -21,12 +22,14 @@ from psnawp_api.models.group.group_datatypes import GroupDetails
from psnawp_api.models.trophies import TrophyTitle
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
+from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -45,6 +48,7 @@ class PlaystationNetworkRuntimeData:
trophy_titles: PlaystationNetworkTrophyTitlesCoordinator
groups: PlaystationNetworkGroupsUpdateCoordinator
friends: dict[str, PlaystationNetworkFriendDataCoordinator]
+ friends_list: PlaystationNetworkFriendlistCoordinator
class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
@@ -134,6 +138,25 @@ class PlaystationNetworkTrophyTitlesCoordinator(
return self.psn.trophy_titles
+class PlaystationNetworkFriendlistCoordinator(
+ PlayStationNetworkBaseCoordinator[dict[str, User]]
+):
+ """Friend list data update coordinator for PSN."""
+
+ _update_interval = timedelta(hours=3)
+
+ async def update_data(self) -> dict[str, User]:
+ """Update trophy titles data."""
+
+ self.psn.friends_list = await self.hass.async_add_executor_job(
+ lambda: {
+ friend.account_id: friend for friend in self.psn.user.friends_list()
+ }
+ )
+ await self.config_entry.runtime_data.user_data.async_request_refresh()
+ return self.psn.friends_list
+
+
class PlaystationNetworkGroupsUpdateCoordinator(
PlayStationNetworkBaseCoordinator[dict[str, GroupDetails]]
):
@@ -143,13 +166,34 @@ class PlaystationNetworkGroupsUpdateCoordinator(
async def update_data(self) -> dict[str, GroupDetails]:
"""Update groups data."""
- return await self.hass.async_add_executor_job(
- lambda: {
- group_info.group_id: group_info.get_group_information()
- for group_info in self.psn.client.get_groups()
- if not group_info.group_id.startswith("~")
- }
- )
+ try:
+ return await self.hass.async_add_executor_job(
+ lambda: {
+ group_info.group_id: group_info.get_group_information()
+ for group_info in self.psn.client.get_groups()
+ if not group_info.group_id.startswith("~")
+ }
+ )
+ except PSNAWPForbiddenError as e:
+ try:
+ error = json.loads(e.args[0])
+ except json.JSONDecodeError as err:
+ raise PSNAWPServerError from err
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"group_chat_forbidden_{self.config_entry.entry_id}",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="group_chat_forbidden",
+ translation_placeholders={
+ CONF_NAME: self.config_entry.title,
+ "error_message": error["error"]["message"],
+ },
+ )
+ await self.async_shutdown()
+ return {}
class PlaystationNetworkFriendDataCoordinator(
@@ -178,7 +222,10 @@ class PlaystationNetworkFriendDataCoordinator(
"""Set up the coordinator."""
if TYPE_CHECKING:
assert self.subentry.unique_id
- self.user = self.psn.psn.user(account_id=self.subentry.unique_id)
+ self.user = self.psn.friends_list.get(
+ self.subentry.unique_id
+ ) or self.psn.psn.user(account_id=self.subentry.unique_id)
+
self.profile = self.user.profile()
async def _async_setup(self) -> None:
diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py
index 492a011cf78..d456cc110a4 100644
--- a/homeassistant/components/playstation_network/helpers.py
+++ b/homeassistant/components/playstation_network/helpers.py
@@ -60,7 +60,7 @@ class PlaystationNetwork:
self.legacy_profile: dict[str, Any] | None = None
self.trophy_titles: list[TrophyTitle] = []
self._title_icon_urls: dict[str, str] = {}
- self.friends_list: dict[str, User] | None = None
+ self.friends_list: dict[str, User] = {}
def _setup(self) -> None:
"""Setup PSN."""
@@ -68,6 +68,9 @@ class PlaystationNetwork:
self.client = self.psn.me()
self.shareable_profile_link = self.client.get_shareable_profile_link()
self.trophy_titles = list(self.user.trophy_titles(page_size=500))
+ self.friends_list = {
+ friend.account_id: friend for friend in self.user.friends_list()
+ }
async def async_setup(self) -> None:
"""Setup PSN."""
diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json
index 590bd73fbf7..559d91b82fb 100644
--- a/homeassistant/components/playstation_network/manifest.json
+++ b/homeassistant/components/playstation_network/manifest.json
@@ -81,5 +81,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
- "requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.7.0"]
+ "requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.9.0"]
}
diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py
index a06359ebffc..25c01960e3f 100644
--- a/homeassistant/components/playstation_network/notify.py
+++ b/homeassistant/components/playstation_network/notify.py
@@ -18,7 +18,7 @@ from homeassistant.components.notify import (
NotifyEntity,
NotifyEntityDescription,
)
-from homeassistant.config_entries import ConfigSubentry
+from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
PlaystationNetworkConfigEntry,
- PlaystationNetworkFriendDataCoordinator,
+ PlaystationNetworkFriendlistCoordinator,
PlaystationNetworkGroupsUpdateCoordinator,
)
from .entity import PlaystationNetworkServiceEntity
@@ -50,8 +50,10 @@ async def async_setup_entry(
"""Set up the notify entity platform."""
coordinator = config_entry.runtime_data.groups
+ friends_list = config_entry.runtime_data.friends_list
groups_added: set[str] = set()
+ friends_added: set[str] = set()
entity_registry = er.async_get(hass)
@callback
@@ -78,16 +80,32 @@ async def async_setup_entry(
coordinator.async_add_listener(add_entities)
add_entities()
- for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items():
- async_add_entities(
- [
- PlaystationNetworkDirectMessageNotifyEntity(
- friend_coordinator,
- config_entry.subentries[subentry_id],
- )
- ],
- config_subentry_id=subentry_id,
- )
+ @callback
+ def add_dm_entities() -> None:
+ nonlocal friends_added
+
+ new_friends = set(friends_list.psn.friends_list.keys()) - friends_added
+ if new_friends:
+ async_add_entities(
+ [
+ PlaystationNetworkDirectMessageNotifyEntity(
+ friends_list, account_id
+ )
+ for account_id in new_friends
+ ],
+ )
+ friends_added |= new_friends
+ deleted_friends = friends_added - set(coordinator.psn.friends_list.keys())
+ for account_id in deleted_friends:
+ if entity_id := entity_registry.async_get_entity_id(
+ NOTIFY_DOMAIN,
+ DOMAIN,
+ f"{coordinator.config_entry.unique_id}_{account_id}_{PlaystationNetworkNotify.DIRECT_MESSAGE}",
+ ):
+ entity_registry.async_remove(entity_id)
+
+ friends_list.async_add_listener(add_dm_entities)
+ add_dm_entities()
class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity):
@@ -95,12 +113,17 @@ class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, Notify
group: Group | None = None
- def send_message(self, message: str, title: str | None = None) -> None:
- """Send a message."""
+ def _send_message(self, message: str) -> None:
+ """Send message."""
if TYPE_CHECKING:
assert self.group
+ self.group.send_message(message)
+
+ def send_message(self, message: str, title: str | None = None) -> None:
+ """Send a message."""
+
try:
- self.group.send_message(message)
+ self._send_message(message)
except PSNAWPNotFoundError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -138,7 +161,7 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity):
key=group_id,
translation_key=PlaystationNetworkNotify.GROUP_MESSAGE,
translation_placeholders={
- "group_name": group_details["groupName"]["value"]
+ CONF_NAME: group_details["groupName"]["value"]
or ", ".join(
member["onlineId"]
for member in group_details["members"]
@@ -153,27 +176,29 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity):
class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity):
"""Representation of a PlayStation Network notify entity for sending direct messages."""
- coordinator: PlaystationNetworkFriendDataCoordinator
+ coordinator: PlaystationNetworkFriendlistCoordinator
def __init__(
self,
- coordinator: PlaystationNetworkFriendDataCoordinator,
- subentry: ConfigSubentry,
+ coordinator: PlaystationNetworkFriendlistCoordinator,
+ account_id: str,
) -> None:
"""Initialize a notification entity."""
-
+ self.account_id = account_id
self.entity_description = NotifyEntityDescription(
- key=PlaystationNetworkNotify.DIRECT_MESSAGE,
+ key=f"{account_id}_{PlaystationNetworkNotify.DIRECT_MESSAGE}",
translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE,
+ translation_placeholders={
+ CONF_NAME: coordinator.psn.friends_list[account_id].online_id
+ },
+ entity_registry_enabled_default=False,
)
- super().__init__(coordinator, self.entity_description, subentry)
-
- def send_message(self, message: str, title: str | None = None) -> None:
- """Send a message."""
+ super().__init__(coordinator, self.entity_description)
+ def _send_message(self, message: str) -> None:
if not self.group:
self.group = self.coordinator.psn.psn.group(
- users_list=[self.coordinator.user]
+ users_list=[self.coordinator.psn.friends_list[self.account_id]]
)
- super().send_message(message, title)
+ super()._send_message(message)
diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json
index 15b83b7cd0d..72648be2cc2 100644
--- a/homeassistant/components/playstation_network/strings.json
+++ b/homeassistant/components/playstation_network/strings.json
@@ -82,13 +82,13 @@
"message": "Data retrieval failed when trying to access the PlayStation Network."
},
"group_invalid": {
- "message": "Failed to send message to group {group_name}. The group is invalid or does not exist."
+ "message": "Failed to send message to group {name}. The group is invalid or does not exist."
},
"send_message_forbidden": {
- "message": "Failed to send message to group {group_name}. You are not allowed to send messages to this group."
+ "message": "Failed to send message to {name}. You are not allowed to send messages to this group or friend."
},
"send_message_failed": {
- "message": "Failed to send message to group {group_name}. Try again later."
+ "message": "Failed to send message to {name}. Try again later."
},
"user_profile_private": {
"message": "Unable to retrieve data for {user}. Privacy settings restrict access to activity."
@@ -158,11 +158,17 @@
},
"notify": {
"group_message": {
- "name": "Group: {group_name}"
+ "name": "Group: {name}"
},
"direct_message": {
- "name": "Direct message"
+ "name": "Direct message: {name}"
}
}
+ },
+ "issues": {
+ "group_chat_forbidden": {
+ "title": "Failed to retrieve group chats for {name}",
+ "description": "The PlayStation Network integration was unable to retrieve group chats for **{name}**.\n\nThis is likely due to insufficient permissions (Error: `{error_message}`).\n\nTo resolve this issue, please ensure the account's chat and messaging feature is not restricted by parental controls or other privacy settings.\n\nIf the restriction is intentional, you can safely ignore this message."
+ }
}
}
diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py
index f1816f03d3b..831f50b1a9e 100644
--- a/homeassistant/components/plum_lightpad/__init__.py
+++ b/homeassistant/components/plum_lightpad/__init__.py
@@ -1,52 +1,36 @@
"""Support for Plum Lightpad devices."""
-import logging
-
-from aiohttp import ContentTypeError
-from requests.exceptions import ConnectTimeout, HTTPError
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_USERNAME,
- EVENT_HOMEASSISTANT_STOP,
- Platform,
-)
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import issue_registry as ir
-from .const import DOMAIN
-from .utils import load_plum
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORMS = [Platform.LIGHT]
+DOMAIN = "plum_lightpad"
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Set up Plum Lightpad from a config entry."""
- _LOGGER.debug("Setting up config entry with ID = %s", entry.unique_id)
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ DOMAIN,
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="integration_removed",
+ translation_placeholders={
+ "entries": "/config/integrations/integration/plum_lightpad",
+ },
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload config entry."""
+ if all(
+ config_entry.state is ConfigEntryState.NOT_LOADED
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.entry_id != entry.entry_id
+ ):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
- username = entry.data[CONF_USERNAME]
- password = entry.data[CONF_PASSWORD]
-
- try:
- plum = await load_plum(username, password, hass)
- except ContentTypeError as ex:
- _LOGGER.error("Unable to authenticate to Plum cloud: %s", ex)
- return False
- except (ConnectTimeout, HTTPError) as ex:
- _LOGGER.error("Unable to connect to Plum cloud: %s", ex)
- raise ConfigEntryNotReady from ex
-
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = plum
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
- def cleanup(event):
- """Clean up resources."""
- plum.cleanup()
-
- entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup))
return True
diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py
index 2a929d14c9e..4a0b849d939 100644
--- a/homeassistant/components/plum_lightpad/config_flow.py
+++ b/homeassistant/components/plum_lightpad/config_flow.py
@@ -2,59 +2,12 @@
from __future__ import annotations
-import logging
-from typing import Any
+from homeassistant.config_entries import ConfigFlow
-from aiohttp import ContentTypeError
-from requests.exceptions import ConnectTimeout, HTTPError
-import voluptuous as vol
-
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-
-from .const import DOMAIN
-from .utils import load_plum
-
-_LOGGER = logging.getLogger(__name__)
+from . import DOMAIN
class PlumLightpadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Plum Lightpad integration."""
VERSION = 1
-
- def _show_form(self, errors=None):
- schema = {
- vol.Required(CONF_USERNAME): str,
- vol.Required(CONF_PASSWORD): str,
- }
-
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema(schema),
- errors=errors or {},
- )
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle a flow initialized by the user or redirected to by import."""
- if not user_input:
- return self._show_form()
-
- username = user_input[CONF_USERNAME]
- password = user_input[CONF_PASSWORD]
-
- # load Plum just so we know username/password work
- try:
- await load_plum(username, password, self.hass)
- except (ContentTypeError, ConnectTimeout, HTTPError) as ex:
- _LOGGER.error("Unable to connect/authenticate to Plum cloud: %s", str(ex))
- return self._show_form({"base": "cannot_connect"})
-
- await self.async_set_unique_id(username)
- self._abort_if_unique_id_configured()
-
- return self.async_create_entry(
- title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}
- )
diff --git a/homeassistant/components/plum_lightpad/const.py b/homeassistant/components/plum_lightpad/const.py
deleted file mode 100644
index efea35d0a7a..00000000000
--- a/homeassistant/components/plum_lightpad/const.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Constants for the Plum Lightpad component."""
-
-DOMAIN = "plum_lightpad"
diff --git a/homeassistant/components/plum_lightpad/icons.json b/homeassistant/components/plum_lightpad/icons.json
deleted file mode 100644
index dd65160e474..00000000000
--- a/homeassistant/components/plum_lightpad/icons.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "entity": {
- "light": {
- "glow_ring": {
- "default": "mdi:crop-portrait"
- }
- }
- }
-}
diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py
deleted file mode 100644
index 78743c12808..00000000000
--- a/homeassistant/components/plum_lightpad/light.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""Support for Plum Lightpad lights."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from plumlightpad import Plum
-
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS,
- ATTR_HS_COLOR,
- ColorMode,
- LightEntity,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.util import color as color_util
-
-from .const import DOMAIN
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Set up Plum Lightpad dimmer lights and glow rings."""
-
- plum: Plum = hass.data[DOMAIN][entry.entry_id]
-
- def setup_entities(device) -> None:
- entities: list[LightEntity] = []
-
- if "lpid" in device:
- lightpad = plum.get_lightpad(device["lpid"])
- entities.append(GlowRing(lightpad=lightpad))
-
- if "llid" in device:
- logical_load = plum.get_load(device["llid"])
- entities.append(PlumLight(load=logical_load))
-
- async_add_entities(entities)
-
- async def new_load(device):
- setup_entities(device)
-
- async def new_lightpad(device):
- setup_entities(device)
-
- device_web_session = async_get_clientsession(hass, verify_ssl=False)
- entry.async_create_background_task(
- hass,
- plum.discover(
- hass.loop,
- loadListener=new_load,
- lightpadListener=new_lightpad,
- websession=device_web_session,
- ),
- "plum.light-discover",
- )
-
-
-class PlumLight(LightEntity):
- """Representation of a Plum Lightpad dimmer."""
-
- _attr_should_poll = False
- _attr_has_entity_name = True
- _attr_name = None
-
- def __init__(self, load):
- """Initialize the light."""
- self._load = load
- self._brightness = load.level
- unique_id = f"{load.llid}.light"
- self._attr_unique_id = unique_id
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, unique_id)},
- manufacturer="Plum",
- model="Dimmer",
- name=load.name,
- )
-
- async def async_added_to_hass(self) -> None:
- """Subscribe to dimmerchange events."""
- self._load.add_event_listener("dimmerchange", self.dimmerchange)
-
- def dimmerchange(self, event):
- """Change event handler updating the brightness."""
- self._brightness = event["level"]
- self.schedule_update_ha_state()
-
- @property
- def brightness(self) -> int:
- """Return the brightness of this switch between 0..255."""
- return self._brightness
-
- @property
- def is_on(self) -> bool:
- """Return true if light is on."""
- return self._brightness > 0
-
- @property
- def color_mode(self) -> ColorMode:
- """Flag supported features."""
- if self._load.dimmable:
- return ColorMode.BRIGHTNESS
- return ColorMode.ONOFF
-
- @property
- def supported_color_modes(self) -> set[ColorMode]:
- """Flag supported color modes."""
- return {self.color_mode}
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn the light on."""
- if ATTR_BRIGHTNESS in kwargs:
- await self._load.turn_on(kwargs[ATTR_BRIGHTNESS])
- else:
- await self._load.turn_on()
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn the light off."""
- await self._load.turn_off()
-
-
-class GlowRing(LightEntity):
- """Representation of a Plum Lightpad dimmer glow ring."""
-
- _attr_color_mode = ColorMode.HS
- _attr_should_poll = False
- _attr_translation_key = "glow_ring"
- _attr_supported_color_modes = {ColorMode.HS}
-
- def __init__(self, lightpad):
- """Initialize the light."""
- self._lightpad = lightpad
- self._attr_name = f"{lightpad.friendly_name} Glow Ring"
-
- self._attr_is_on = lightpad.glow_enabled
- self._glow_intensity = lightpad.glow_intensity
- unique_id = f"{self._lightpad.lpid}.glow"
- self._attr_unique_id = unique_id
-
- self._red = lightpad.glow_color["red"]
- self._green = lightpad.glow_color["green"]
- self._blue = lightpad.glow_color["blue"]
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, unique_id)},
- manufacturer="Plum",
- model="Glow Ring",
- name=self._attr_name,
- )
-
- async def async_added_to_hass(self) -> None:
- """Subscribe to configchange events."""
- self._lightpad.add_event_listener("configchange", self.configchange_event)
-
- def configchange_event(self, event):
- """Handle Configuration change event."""
- config = event["changes"]
-
- self._attr_is_on = config["glowEnabled"]
- self._glow_intensity = config["glowIntensity"]
-
- self._red = config["glowColor"]["red"]
- self._green = config["glowColor"]["green"]
- self._blue = config["glowColor"]["blue"]
- self.schedule_update_ha_state()
-
- @property
- def hs_color(self):
- """Return the hue and saturation color value [float, float]."""
- return color_util.color_RGB_to_hs(self._red, self._green, self._blue)
-
- @property
- def brightness(self) -> int:
- """Return the brightness of this switch between 0..255."""
- return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255)
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn the light on."""
- if ATTR_BRIGHTNESS in kwargs:
- brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
- await self._lightpad.set_config({"glowIntensity": brightness_pct})
- elif ATTR_HS_COLOR in kwargs:
- hs_color = kwargs[ATTR_HS_COLOR]
- red, green, blue = color_util.color_hs_to_RGB(*hs_color)
- await self._lightpad.set_glow_color(red, green, blue, 0)
- else:
- await self._lightpad.set_config({"glowEnabled": True})
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn the light off."""
- if ATTR_BRIGHTNESS in kwargs:
- brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
- await self._lightpad.set_config({"glowIntensity": brightness_pct})
- else:
- await self._lightpad.set_config({"glowEnabled": False})
diff --git a/homeassistant/components/plum_lightpad/manifest.json b/homeassistant/components/plum_lightpad/manifest.json
index ffe2b47a0c6..eee716d77e3 100644
--- a/homeassistant/components/plum_lightpad/manifest.json
+++ b/homeassistant/components/plum_lightpad/manifest.json
@@ -1,10 +1,9 @@
{
"domain": "plum_lightpad",
"name": "Plum Lightpad",
- "codeowners": ["@ColinHarrington", "@prystupa"],
- "config_flow": true,
+ "codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/plum_lightpad",
+ "integration_type": "system",
"iot_class": "local_push",
- "loggers": ["plumlightpad"],
- "requirements": ["plumlightpad==0.0.11"]
+ "requirements": []
}
diff --git a/homeassistant/components/plum_lightpad/strings.json b/homeassistant/components/plum_lightpad/strings.json
index 935e1614696..d0268287d47 100644
--- a/homeassistant/components/plum_lightpad/strings.json
+++ b/homeassistant/components/plum_lightpad/strings.json
@@ -1,18 +1,8 @@
{
- "config": {
- "step": {
- "user": {
- "data": {
- "username": "[%key:common::config_flow::data::email%]",
- "password": "[%key:common::config_flow::data::password%]"
- }
- }
- },
- "error": {
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
- },
- "abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ "issues": {
+ "integration_removed": {
+ "title": "The Plum Lightpad integration has been removed",
+ "description": "The Plum Lightpad integration has been removed from Home Assistant.\n\nThe required cloud services are no longer available since the Plum servers have been shut down. To resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Plum Lightpad integration entries]({entries})."
}
}
}
diff --git a/homeassistant/components/plum_lightpad/utils.py b/homeassistant/components/plum_lightpad/utils.py
deleted file mode 100644
index 6704b443d72..00000000000
--- a/homeassistant/components/plum_lightpad/utils.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""Reusable utilities for the Plum Lightpad component."""
-
-from plumlightpad import Plum
-
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-
-
-async def load_plum(username: str, password: str, hass: HomeAssistant) -> Plum:
- """Initialize Plum Lightpad API and load metadata stored in the cloud."""
- plum = Plum(username, password)
- cloud_web_session = async_get_clientsession(hass, verify_ssl=True)
- await plum.loadCloudData(cloud_web_session)
- return plum
diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py
new file mode 100644
index 00000000000..4de98bbc6d9
--- /dev/null
+++ b/homeassistant/components/pooldose/__init__.py
@@ -0,0 +1,58 @@
+"""The Seko Pooldose integration."""
+
+from __future__ import annotations
+
+import logging
+
+from pooldose.client import PooldoseClient
+from pooldose.request_status import RequestStatus
+
+from homeassistant.const import CONF_HOST, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .coordinator import PooldoseConfigEntry, PooldoseCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
+ """Set up Seko PoolDose from a config entry."""
+ # Get host from config entry data (connection-critical configuration)
+ host = entry.data[CONF_HOST]
+
+ # Create the PoolDose API client and connect
+ client = PooldoseClient(host)
+ try:
+ client_status = await client.connect()
+ except TimeoutError as err:
+ raise ConfigEntryNotReady(
+ f"Timeout connecting to PoolDose device: {err}"
+ ) from err
+ except (ConnectionError, OSError) as err:
+ raise ConfigEntryNotReady(
+ f"Failed to connect to PoolDose device: {err}"
+ ) from err
+
+ if client_status != RequestStatus.SUCCESS:
+ raise ConfigEntryNotReady(
+ f"Failed to create PoolDose client while initialization: {client_status}"
+ )
+
+ # Create coordinator and perform first refresh
+ coordinator = PooldoseCoordinator(hass, client, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ # Store runtime data
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
+ """Unload the Seko PoolDose entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/pooldose/config_flow.py b/homeassistant/components/pooldose/config_flow.py
new file mode 100644
index 00000000000..6deb4eafb13
--- /dev/null
+++ b/homeassistant/components/pooldose/config_flow.py
@@ -0,0 +1,135 @@
+"""Config flow for the Seko PoolDose integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from pooldose.client import PooldoseClient
+from pooldose.request_status import RequestStatus
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_MAC
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SCHEMA_DEVICE = vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ }
+)
+
+
+class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Config flow for the Pooldose integration including DHCP discovery."""
+
+ VERSION = 1
+
+ def __init__(self) -> None:
+ """Initialize the config flow and store the discovered IP address and MAC."""
+ super().__init__()
+ self._discovered_ip: str | None = None
+ self._discovered_mac: str | None = None
+
+ async def _validate_host(
+ self, host: str
+ ) -> tuple[str | None, dict[str, str] | None, dict[str, str] | None]:
+ """Validate the host and return (serial_number, api_versions, errors)."""
+ client = PooldoseClient(host)
+ client_status = await client.connect()
+ if client_status == RequestStatus.HOST_UNREACHABLE:
+ return None, None, {"base": "cannot_connect"}
+ if client_status == RequestStatus.PARAMS_FETCH_FAILED:
+ return None, None, {"base": "params_fetch_failed"}
+ if client_status != RequestStatus.SUCCESS:
+ return None, None, {"base": "cannot_connect"}
+
+ api_status, api_versions = client.check_apiversion_supported()
+ if api_status == RequestStatus.NO_DATA:
+ return None, None, {"base": "api_not_set"}
+ if api_status == RequestStatus.API_VERSION_UNSUPPORTED:
+ return None, api_versions, {"base": "api_not_supported"}
+
+ device_info = client.device_info
+ if not device_info:
+ return None, None, {"base": "no_device_info"}
+ serial_number = device_info.get("SERIAL_NUMBER")
+ if not serial_number:
+ return None, None, {"base": "no_serial_number"}
+
+ return serial_number, None, None
+
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle DHCP discovery: validate device and update IP if needed."""
+ serial_number, _, _ = await self._validate_host(discovery_info.ip)
+ if not serial_number:
+ return self.async_abort(reason="no_serial_number")
+
+ # If an existing entry is found
+ existing_entry = await self.async_set_unique_id(serial_number)
+ if existing_entry:
+ # Only update the MAC if it's not already set
+ if CONF_MAC not in existing_entry.data:
+ self.hass.config_entries.async_update_entry(
+ existing_entry,
+ data={**existing_entry.data, CONF_MAC: discovery_info.macaddress},
+ )
+ self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
+
+ # Else: Continue with new flow
+ self._discovered_ip = discovery_info.ip
+ self._discovered_mac = discovery_info.macaddress
+ return self.async_show_form(
+ step_id="dhcp_confirm",
+ description_placeholders={
+ "ip": discovery_info.ip,
+ "mac": discovery_info.macaddress,
+ "name": f"PoolDose {serial_number}",
+ },
+ )
+
+ async def async_step_dhcp_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Create the entry after the confirmation dialog."""
+ return self.async_create_entry(
+ title=f"PoolDose {self.unique_id}",
+ data={
+ CONF_HOST: self._discovered_ip,
+ CONF_MAC: self._discovered_mac,
+ },
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ if not user_input:
+ return self.async_show_form(
+ step_id="user",
+ data_schema=SCHEMA_DEVICE,
+ )
+
+ host = user_input[CONF_HOST]
+ serial_number, api_versions, errors = await self._validate_host(host)
+ if errors:
+ return self.async_show_form(
+ step_id="user",
+ data_schema=SCHEMA_DEVICE,
+ errors=errors,
+ description_placeholders=api_versions,
+ )
+
+ await self.async_set_unique_id(serial_number, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=f"PoolDose {serial_number}",
+ data={CONF_HOST: host},
+ )
diff --git a/homeassistant/components/pooldose/const.py b/homeassistant/components/pooldose/const.py
new file mode 100644
index 00000000000..7b8d978431a
--- /dev/null
+++ b/homeassistant/components/pooldose/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Seko Pooldose integration."""
+
+from __future__ import annotations
+
+DOMAIN = "pooldose"
+MANUFACTURER = "SEKO"
diff --git a/homeassistant/components/pooldose/coordinator.py b/homeassistant/components/pooldose/coordinator.py
new file mode 100644
index 00000000000..cd2fa5d991d
--- /dev/null
+++ b/homeassistant/components/pooldose/coordinator.py
@@ -0,0 +1,69 @@
+"""Data update coordinator for the PoolDose integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Any
+
+from pooldose.client import PooldoseClient
+from pooldose.request_status import RequestStatus
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+type PooldoseConfigEntry = ConfigEntry[PooldoseCoordinator]
+
+
+class PooldoseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Coordinator for PoolDose integration."""
+
+ device_info: dict[str, Any]
+ config_entry: PooldoseConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: PooldoseClient,
+ config_entry: PooldoseConfigEntry,
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Pooldose",
+ update_interval=timedelta(seconds=600), # Default update interval
+ config_entry=config_entry,
+ )
+ self.client = client
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ # Update device info after successful connection
+ self.device_info = self.client.device_info
+ _LOGGER.debug("Device info: %s", self.device_info)
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ """Fetch data from the PoolDose API."""
+ try:
+ status, instant_values = await self.client.instant_values_structured()
+ except TimeoutError as err:
+ raise UpdateFailed(
+ f"Timeout fetching data from PoolDose device: {err}"
+ ) from err
+ except (ConnectionError, OSError) as err:
+ raise UpdateFailed(
+ f"Failed to connect to PoolDose device while fetching data: {err}"
+ ) from err
+
+ if status != RequestStatus.SUCCESS:
+ raise UpdateFailed(f"API returned status: {status}")
+
+ if instant_values is None:
+ raise UpdateFailed("No data received from API")
+
+ _LOGGER.debug("Instant values structured: %s", instant_values)
+ return instant_values
diff --git a/homeassistant/components/pooldose/entity.py b/homeassistant/components/pooldose/entity.py
new file mode 100644
index 00000000000..06c617ad524
--- /dev/null
+++ b/homeassistant/components/pooldose/entity.py
@@ -0,0 +1,83 @@
+"""Base entity for Seko Pooldose integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.const import CONF_MAC
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, MANUFACTURER
+from .coordinator import PooldoseCoordinator
+
+
+def device_info(
+ info: dict | None, unique_id: str, mac: str | None = None
+) -> DeviceInfo:
+ """Create device info for PoolDose devices."""
+ if info is None:
+ info = {}
+
+ api_version = info.get("API_VERSION", "").removesuffix("/")
+
+ return DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ manufacturer=MANUFACTURER,
+ model=info.get("MODEL") or None,
+ model_id=info.get("MODEL_ID") or None,
+ name=info.get("NAME") or None,
+ serial_number=unique_id,
+ sw_version=(
+ f"{info.get('FW_VERSION')} (SW v{info.get('SW_VERSION')}, API {api_version})"
+ if info.get("FW_VERSION") and info.get("SW_VERSION") and api_version
+ else None
+ ),
+ hw_version=info.get("FW_CODE") or None,
+ configuration_url=(
+ f"http://{info['IP']}/index.html" if info.get("IP") else None
+ ),
+ connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
+ )
+
+
+class PooldoseEntity(CoordinatorEntity[PooldoseCoordinator]):
+ """Base class for all PoolDose entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: PooldoseCoordinator,
+ serial_number: str,
+ device_properties: dict[str, Any],
+ entity_description: EntityDescription,
+ platform_name: str,
+ ) -> None:
+ """Initialize PoolDose entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self.platform_name = platform_name
+ self._attr_unique_id = f"{serial_number}_{entity_description.key}"
+ self._attr_device_info = device_info(
+ device_properties,
+ serial_number,
+ coordinator.config_entry.data.get(CONF_MAC),
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if the entity is available."""
+ if not super().available or self.coordinator.data is None:
+ return False
+ # Check if the entity type exists in coordinator data
+ platform_data = self.coordinator.data.get(self.platform_name, {})
+ return self.entity_description.key in platform_data
+
+ def get_data(self) -> dict | None:
+ """Get data for this entity, only if available."""
+ if not self.available:
+ return None
+ platform_data = self.coordinator.data.get(self.platform_name, {})
+ return platform_data.get(self.entity_description.key)
diff --git a/homeassistant/components/pooldose/icons.json b/homeassistant/components/pooldose/icons.json
new file mode 100644
index 00000000000..4a51b4fdc14
--- /dev/null
+++ b/homeassistant/components/pooldose/icons.json
@@ -0,0 +1,45 @@
+{
+ "entity": {
+ "sensor": {
+ "orp": {
+ "default": "mdi:water-check"
+ },
+ "ph_type_dosing": {
+ "default": "mdi:flask"
+ },
+ "peristaltic_ph_dosing": {
+ "default": "mdi:pump"
+ },
+ "ofa_ph_value": {
+ "default": "mdi:clock"
+ },
+ "orp_type_dosing": {
+ "default": "mdi:flask"
+ },
+ "peristaltic_orp_dosing": {
+ "default": "mdi:pump"
+ },
+ "ofa_orp_value": {
+ "default": "mdi:clock"
+ },
+ "ph_calibration_type": {
+ "default": "mdi:form-select"
+ },
+ "ph_calibration_offset": {
+ "default": "mdi:tune"
+ },
+ "ph_calibration_slope": {
+ "default": "mdi:slope-downhill"
+ },
+ "orp_calibration_type": {
+ "default": "mdi:form-select"
+ },
+ "orp_calibration_offset": {
+ "default": "mdi:tune"
+ },
+ "orp_calibration_slope": {
+ "default": "mdi:slope-downhill"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json
new file mode 100644
index 00000000000..5328edce108
--- /dev/null
+++ b/homeassistant/components/pooldose/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "pooldose",
+ "name": "SEKO PoolDose",
+ "codeowners": ["@lmaertin"],
+ "config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "kommspot"
+ }
+ ],
+ "documentation": "https://www.home-assistant.io/integrations/pooldose",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["python-pooldose==0.5.0"]
+}
diff --git a/homeassistant/components/pooldose/quality_scale.yaml b/homeassistant/components/pooldose/quality_scale.yaml
new file mode 100644
index 00000000000..3c685e8c511
--- /dev/null
+++ b/homeassistant/components/pooldose/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide any actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: This integration does not provide any actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: This integration does not explicitly subscribe to any events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: This integration does not provide any actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: This integration does not need authentication for the local API.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: This integration does not support dynamic devices, as it is designed for a single PoolDose device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: This integration does not provide repair issues, as it is designed for a single PoolDose device with a fixed configuration.
+ stale-devices:
+ status: exempt
+ comment: This integration does not support stale devices, as it is designed for a single PoolDose device with a fixed configuration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py
new file mode 100644
index 00000000000..14c2647d27b
--- /dev/null
+++ b/homeassistant/components/pooldose/sensor.py
@@ -0,0 +1,186 @@
+"""Sensors for the Seko PoolDose integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PooldoseConfigEntry
+from .entity import PooldoseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_NAME = "sensor"
+
+SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ # Unit dynamically determined via API
+ ),
+ SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
+ SensorEntityDescription(
+ key="orp",
+ translation_key="orp",
+ device_class=SensorDeviceClass.VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ ),
+ SensorEntityDescription(
+ key="ph_type_dosing",
+ translation_key="ph_type_dosing",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.ENUM,
+ options=["alcalyne", "acid"],
+ ),
+ SensorEntityDescription(
+ key="peristaltic_ph_dosing",
+ translation_key="peristaltic_ph_dosing",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=["proportional", "on_off", "timed"],
+ ),
+ SensorEntityDescription(
+ key="ofa_ph_value",
+ translation_key="ofa_ph_value",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ SensorEntityDescription(
+ key="orp_type_dosing",
+ translation_key="orp_type_dosing",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=["low", "high"],
+ ),
+ SensorEntityDescription(
+ key="peristaltic_orp_dosing",
+ translation_key="peristaltic_orp_dosing",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=["off", "proportional", "on_off", "timed"],
+ ),
+ SensorEntityDescription(
+ key="ofa_orp_value",
+ translation_key="ofa_orp_value",
+ device_class=SensorDeviceClass.DURATION,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ SensorEntityDescription(
+ key="ph_calibration_type",
+ translation_key="ph_calibration_type",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=["off", "reference", "1_point", "2_points"],
+ ),
+ SensorEntityDescription(
+ key="ph_calibration_offset",
+ translation_key="ph_calibration_offset",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLTAGE,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ ),
+ SensorEntityDescription(
+ key="ph_calibration_slope",
+ translation_key="ph_calibration_slope",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLTAGE,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ ),
+ SensorEntityDescription(
+ key="orp_calibration_type",
+ translation_key="orp_calibration_type",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=["off", "reference", "1_point"],
+ ),
+ SensorEntityDescription(
+ key="orp_calibration_offset",
+ translation_key="orp_calibration_offset",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLTAGE,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ ),
+ SensorEntityDescription(
+ key="orp_calibration_slope",
+ translation_key="orp_calibration_slope",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLTAGE,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PooldoseConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up PoolDose sensor entities from a config entry."""
+ if TYPE_CHECKING:
+ assert config_entry.unique_id is not None
+
+ coordinator = config_entry.runtime_data
+ data = coordinator.data
+ serial_number = config_entry.unique_id
+
+ sensor_data = data.get(PLATFORM_NAME, {}) if data else {}
+
+ async_add_entities(
+ PooldoseSensor(
+ coordinator,
+ serial_number,
+ coordinator.device_info,
+ description,
+ PLATFORM_NAME,
+ )
+ for description in SENSOR_DESCRIPTIONS
+ if description.key in sensor_data
+ )
+
+
+class PooldoseSensor(PooldoseEntity, SensorEntity):
+ """Sensor entity for the Seko PoolDose Python API."""
+
+ @property
+ def native_value(self) -> float | int | str | None:
+ """Return the current value of the sensor."""
+ data = self.get_data()
+ if isinstance(data, dict) and "value" in data:
+ return data["value"]
+ return None
+
+ @property
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the unit of measurement."""
+ if self.entity_description.key == "temperature":
+ data = self.get_data()
+ if isinstance(data, dict) and "unit" in data and data["unit"] is not None:
+ return data["unit"] # °C or °F
+
+ return super().native_unit_of_measurement
diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json
new file mode 100644
index 00000000000..59e2ee7a950
--- /dev/null
+++ b/homeassistant/components/pooldose/strings.json
@@ -0,0 +1,109 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Set up SEKO PoolDose device",
+ "description": "Login handling not supported by API. Device password must be deactivated, i.e., set to default value (0000).",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "IP address or hostname of your device"
+ }
+ },
+ "dhcp_confirm": {
+ "title": "Confirm DHCP discovered PoolDose device",
+ "description": "A PoolDose device was found on your network at {ip} with MAC address {mac}.\n\nDo you want to add {name} to Home Assistant?"
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "api_not_set": "API version not found in device response. Device firmware may not be compatible with this integration.",
+ "api_not_supported": "Unsupported API version {api_version_is} (expected: {api_version_should}). Device firmware may not be compatible with this integration.",
+ "params_fetch_failed": "Unable to fetch core parameters from device. Device firmware may not be compatible with this integration.",
+ "no_device_info": "Unable to retrieve device information. Device may not be properly initialized or may be an unsupported model.",
+ "no_serial_number": "No serial number found on the device. Device may not be properly configured or may be an unsupported model.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "no_device_info": "Unable to retrieve device information",
+ "no_serial_number": "No serial number found on the device"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "orp": {
+ "name": "ORP"
+ },
+ "ph_type_dosing": {
+ "name": "pH dosing type",
+ "state": {
+ "alcalyne": "pH+",
+ "acid": "pH-"
+ }
+ },
+ "peristaltic_ph_dosing": {
+ "name": "pH peristaltic dosing",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "proportional": "Proportional",
+ "on_off": "On/Off",
+ "timed": "Timed"
+ }
+ },
+ "ofa_ph_value": {
+ "name": "pH overfeed alert time"
+ },
+ "orp_type_dosing": {
+ "name": "ORP dosing type",
+ "state": {
+ "low": "[%key:common::state::low%]",
+ "high": "[%key:common::state::high%]"
+ }
+ },
+ "peristaltic_orp_dosing": {
+ "name": "ORP peristaltic dosing",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
+ "on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
+ "timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
+ }
+ },
+ "ofa_orp_value": {
+ "name": "ORP overfeed alert time"
+ },
+ "ph_calibration_type": {
+ "name": "pH calibration type",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "reference": "Reference",
+ "1_point": "1 point",
+ "2_points": "2 points"
+ }
+ },
+ "ph_calibration_offset": {
+ "name": "pH calibration offset"
+ },
+ "ph_calibration_slope": {
+ "name": "pH calibration slope"
+ },
+ "orp_calibration_type": {
+ "name": "ORP calibration type",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "reference": "[%key:component::pooldose::entity::sensor::ph_calibration_type::state::reference%]",
+ "1_point": "[%key:component::pooldose::entity::sensor::ph_calibration_type::state::1_point%]"
+ }
+ },
+ "orp_calibration_offset": {
+ "name": "ORP calibration offset"
+ },
+ "orp_calibration_slope": {
+ "name": "ORP calibration slope"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py
new file mode 100644
index 00000000000..ba78ee32409
--- /dev/null
+++ b/homeassistant/components/portainer/__init__.py
@@ -0,0 +1,65 @@
+"""The Portainer integration."""
+
+from __future__ import annotations
+
+from pyportainer import Portainer
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_API_TOKEN,
+ CONF_HOST,
+ CONF_URL,
+ CONF_VERIFY_SSL,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+from .coordinator import PortainerCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
+
+type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
+ """Set up Portainer from a config entry."""
+
+ client = Portainer(
+ api_url=entry.data[CONF_URL],
+ api_key=entry.data[CONF_API_TOKEN],
+ session=async_create_clientsession(
+ hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
+ ),
+ )
+
+ coordinator = PortainerCoordinator(hass, entry, client)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
+ """Migrate old entry."""
+
+ if entry.version < 2:
+ data = dict(entry.data)
+ data[CONF_URL] = data.pop(CONF_HOST)
+ data[CONF_API_TOKEN] = data.pop(CONF_API_KEY)
+ hass.config_entries.async_update_entry(entry=entry, data=data, version=2)
+
+ if entry.version < 3:
+ data = dict(entry.data)
+ data[CONF_VERIFY_SSL] = True
+ hass.config_entries.async_update_entry(entry=entry, data=data, version=3)
+
+ return True
diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py
new file mode 100644
index 00000000000..032b46ef8b4
--- /dev/null
+++ b/homeassistant/components/portainer/binary_sensor.py
@@ -0,0 +1,146 @@
+"""Binary sensor platform for Portainer."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from pyportainer.models.docker import DockerContainer
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PortainerConfigEntry
+from .coordinator import PortainerCoordinator
+from .entity import (
+ PortainerContainerEntity,
+ PortainerCoordinatorData,
+ PortainerEndpointEntity,
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class PortainerBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Class to hold Portainer binary sensor description."""
+
+ state_fn: Callable[[Any], bool]
+
+
+CONTAINER_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
+ PortainerBinarySensorEntityDescription(
+ key="status",
+ translation_key="status",
+ state_fn=lambda data: data.state == "running",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+ENDPOINT_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
+ PortainerBinarySensorEntityDescription(
+ key="status",
+ translation_key="status",
+ state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped
+ device_class=BinarySensorDeviceClass.RUNNING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PortainerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Portainer binary sensors."""
+ coordinator = entry.runtime_data
+ entities: list[BinarySensorEntity] = []
+
+ for endpoint in coordinator.data.values():
+ entities.extend(
+ PortainerEndpointSensor(
+ coordinator,
+ entity_description,
+ endpoint,
+ )
+ for entity_description in ENDPOINT_SENSORS
+ )
+
+ entities.extend(
+ PortainerContainerSensor(
+ coordinator,
+ entity_description,
+ container,
+ endpoint,
+ )
+ for container in endpoint.containers.values()
+ for entity_description in CONTAINER_SENSORS
+ )
+
+ async_add_entities(entities)
+
+
+class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
+ """Representation of a Portainer endpoint binary sensor entity."""
+
+ entity_description: PortainerBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PortainerCoordinator,
+ entity_description: PortainerBinarySensorEntityDescription,
+ device_info: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize Portainer endpoint binary sensor entity."""
+ self.entity_description = entity_description
+ super().__init__(device_info, coordinator)
+
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ return super().available and self.device_id in self.coordinator.data
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.state_fn(self.coordinator.data[self.device_id])
+
+
+class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
+ """Representation of a Portainer container sensor."""
+
+ entity_description: PortainerBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PortainerCoordinator,
+ entity_description: PortainerBinarySensorEntityDescription,
+ device_info: DockerContainer,
+ via_device: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize the Portainer container sensor."""
+ self.entity_description = entity_description
+ super().__init__(device_info, coordinator, via_device)
+
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ return super().available and self.endpoint_id in self.coordinator.data
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.state_fn(
+ self.coordinator.data[self.endpoint_id].containers[self.device_id]
+ )
diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py
new file mode 100644
index 00000000000..917bc9f4676
--- /dev/null
+++ b/homeassistant/components/portainer/button.py
@@ -0,0 +1,128 @@
+"""Support for Portainer buttons."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from pyportainer import Portainer
+from pyportainer.exceptions import (
+ PortainerAuthenticationError,
+ PortainerConnectionError,
+ PortainerTimeoutError,
+)
+from pyportainer.models.docker import DockerContainer
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PortainerConfigEntry
+from .const import DOMAIN
+from .coordinator import PortainerCoordinator, PortainerCoordinatorData
+from .entity import PortainerContainerEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class PortainerButtonDescription(ButtonEntityDescription):
+ """Class to describe a Portainer button entity."""
+
+ press_action: Callable[
+ [Portainer, int, str],
+ Coroutine[Any, Any, None],
+ ]
+
+
+BUTTONS: tuple[PortainerButtonDescription, ...] = (
+ PortainerButtonDescription(
+ key="restart",
+ name="Restart Container",
+ device_class=ButtonDeviceClass.RESTART,
+ entity_category=EntityCategory.CONFIG,
+ press_action=(
+ lambda portainer, endpoint_id, container_id: portainer.restart_container(
+ endpoint_id, container_id
+ )
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PortainerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Portainer buttons."""
+ coordinator: PortainerCoordinator = entry.runtime_data
+
+ async_add_entities(
+ PortainerButton(
+ coordinator=coordinator,
+ entity_description=entity_description,
+ device_info=container,
+ via_device=endpoint,
+ )
+ for endpoint in coordinator.data.values()
+ for container in endpoint.containers.values()
+ for entity_description in BUTTONS
+ )
+
+
+class PortainerButton(PortainerContainerEntity, ButtonEntity):
+ """Defines a Portainer button."""
+
+ entity_description: PortainerButtonDescription
+
+ def __init__(
+ self,
+ coordinator: PortainerCoordinator,
+ entity_description: PortainerButtonDescription,
+ device_info: DockerContainer,
+ via_device: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize the Portainer button entity."""
+ self.entity_description = entity_description
+ super().__init__(device_info, coordinator, via_device)
+
+ device_identifier = (
+ self._device_info.names[0].replace("/", " ").strip()
+ if self._device_info.names
+ else None
+ )
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
+
+ async def async_press(self) -> None:
+ """Trigger the Portainer button press service."""
+ try:
+ await self.entity_description.press_action(
+ self.coordinator.portainer, self.endpoint_id, self.device_id
+ )
+ except PortainerConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerAuthenticationError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerTimeoutError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py
new file mode 100644
index 00000000000..175e5148847
--- /dev/null
+++ b/homeassistant/components/portainer/config_flow.py
@@ -0,0 +1,143 @@
+"""Config flow for the portainer integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from pyportainer import (
+ Portainer,
+ PortainerAuthenticationError,
+ PortainerConnectionError,
+ PortainerTimeoutError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): str,
+ vol.Required(CONF_API_TOKEN): str,
+ vol.Optional(CONF_VERIFY_SSL, default=True): bool,
+ }
+)
+
+
+async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
+ """Validate the user input allows us to connect."""
+
+ client = Portainer(
+ api_url=data[CONF_URL],
+ api_key=data[CONF_API_TOKEN],
+ session=async_get_clientsession(
+ hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
+ ),
+ )
+ try:
+ await client.get_endpoints()
+ except PortainerAuthenticationError:
+ raise InvalidAuth from None
+ except PortainerConnectionError as err:
+ raise CannotConnect from err
+ except PortainerTimeoutError as err:
+ raise PortainerTimeout from err
+
+ _LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])
+
+
+class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Portainer."""
+
+ VERSION = 2
+
+ 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_URL: user_input[CONF_URL]})
+ try:
+ await _validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except PortainerTimeout:
+ errors["base"] = "timeout_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(user_input[CONF_API_TOKEN])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=user_input[CONF_URL], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth when Portainer API authentication fails."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauth: ask for new API token and validate."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ try:
+ await _validate_input(
+ self.hass,
+ data={
+ **reauth_entry.data,
+ CONF_API_TOKEN: user_input[CONF_API_TOKEN],
+ },
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except PortainerTimeout:
+ errors["base"] = "timeout_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
+ errors=errors,
+ )
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class PortainerTimeout(HomeAssistantError):
+ """Error to indicate a timeout occurred."""
diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py
new file mode 100644
index 00000000000..b9d29a468af
--- /dev/null
+++ b/homeassistant/components/portainer/const.py
@@ -0,0 +1,4 @@
+"""Constants for the Portainer integration."""
+
+DOMAIN = "portainer"
+DEFAULT_NAME = "Portainer"
diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py
new file mode 100644
index 00000000000..e10d6b35584
--- /dev/null
+++ b/homeassistant/components/portainer/coordinator.py
@@ -0,0 +1,137 @@
+"""Data Update Coordinator for Portainer."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from pyportainer import (
+ Portainer,
+ PortainerAuthenticationError,
+ PortainerConnectionError,
+ PortainerTimeoutError,
+)
+from pyportainer.models.docker import DockerContainer
+from pyportainer.models.portainer import Endpoint
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
+
+
+@dataclass
+class PortainerCoordinatorData:
+ """Data class for Portainer Coordinator."""
+
+ id: int
+ name: str | None
+ endpoint: Endpoint
+ containers: dict[str, DockerContainer]
+
+
+class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]):
+ """Data Update Coordinator for Portainer."""
+
+ config_entry: PortainerConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: PortainerConfigEntry,
+ portainer: Portainer,
+ ) -> None:
+ """Initialize the Portainer Data Update Coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
+ self.portainer = portainer
+
+ async def _async_setup(self) -> None:
+ """Set up the Portainer Data Update Coordinator."""
+ try:
+ await self.portainer.get_endpoints()
+ except PortainerAuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerConnectionError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerTimeoutError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+
+ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
+ """Fetch data from Portainer API."""
+ _LOGGER.debug(
+ "Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL]
+ )
+
+ try:
+ endpoints = await self.portainer.get_endpoints()
+ except PortainerAuthenticationError as err:
+ _LOGGER.error("Authentication error: %s", repr(err))
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerConnectionError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ else:
+ _LOGGER.debug("Fetched endpoints: %s", endpoints)
+
+ mapped_endpoints: dict[int, PortainerCoordinatorData] = {}
+ for endpoint in endpoints:
+ try:
+ containers = await self.portainer.get_containers(endpoint.id)
+ except PortainerConnectionError as err:
+ _LOGGER.exception("Connection error")
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerAuthenticationError as err:
+ _LOGGER.exception("Authentication error")
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+
+ mapped_endpoints[endpoint.id] = PortainerCoordinatorData(
+ id=endpoint.id,
+ name=endpoint.name,
+ endpoint=endpoint,
+ containers={container.id: container for container in containers},
+ )
+
+ return mapped_endpoints
diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py
new file mode 100644
index 00000000000..27355bb7c0c
--- /dev/null
+++ b/homeassistant/components/portainer/entity.py
@@ -0,0 +1,81 @@
+"""Base class for Portainer entities."""
+
+from pyportainer.models.docker import DockerContainer
+from yarl import URL
+
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DEFAULT_NAME, DOMAIN
+from .coordinator import PortainerCoordinator, PortainerCoordinatorData
+
+
+class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]):
+ """Base class for Portainer entities."""
+
+ _attr_has_entity_name = True
+
+
+class PortainerEndpointEntity(PortainerCoordinatorEntity):
+ """Base implementation for Portainer endpoint."""
+
+ def __init__(
+ self,
+ device_info: PortainerCoordinatorData,
+ coordinator: PortainerCoordinator,
+ ) -> None:
+ """Initialize a Portainer endpoint."""
+ super().__init__(coordinator)
+ self._device_info = device_info
+ self.device_id = device_info.endpoint.id
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}")
+ },
+ configuration_url=URL(
+ f"{coordinator.config_entry.data[CONF_URL]}#!/{self.device_id}/docker/dashboard"
+ ),
+ manufacturer=DEFAULT_NAME,
+ model="Endpoint",
+ name=device_info.endpoint.name,
+ )
+
+
+class PortainerContainerEntity(PortainerCoordinatorEntity):
+ """Base implementation for Portainer container."""
+
+ def __init__(
+ self,
+ device_info: DockerContainer,
+ coordinator: PortainerCoordinator,
+ via_device: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize a Portainer container."""
+ super().__init__(coordinator)
+ self._device_info = device_info
+ self.device_id = self._device_info.id
+ self.endpoint_id = via_device.endpoint.id
+
+ # Container ID's are ephemeral, so use the container name for the unique ID
+ # The first one, should always be unique, it's fine if users have aliases
+ # According to Docker's API docs, the first name is unique
+ assert self._device_info.names, "Container names list unexpectedly empty"
+ self.device_name = self._device_info.names[0].replace("/", " ").strip()
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_name}")
+ },
+ manufacturer=DEFAULT_NAME,
+ configuration_url=URL(
+ f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/containers/{self.device_id}"
+ ),
+ model="Container",
+ name=self.device_name,
+ via_device=(
+ DOMAIN,
+ f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
+ ),
+ translation_key=None if self.device_name else "unknown_container",
+ )
diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json
new file mode 100644
index 00000000000..316851d2c67
--- /dev/null
+++ b/homeassistant/components/portainer/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "switch": {
+ "container": {
+ "default": "mdi:arrow-down-box",
+ "state": {
+ "on": "mdi:arrow-up-box"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json
new file mode 100644
index 00000000000..e907a5fd6db
--- /dev/null
+++ b/homeassistant/components/portainer/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "portainer",
+ "name": "Portainer",
+ "codeowners": ["@erwindouna"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/portainer",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["pyportainer==1.0.3"]
+}
diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml
new file mode 100644
index 00000000000..d26f0087d87
--- /dev/null
+++ b/homeassistant/components/portainer/quality_scale.yaml
@@ -0,0 +1,77 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ No explicit parallel updates are defined.
+ reauthentication-flow:
+ status: todo
+ comment: |
+ No reauthentication flow is defined. It will be done in a next iteration.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery is implemented, since it's software based.
+ discovery:
+ status: exempt
+ comment: |
+ No discovery is implemented, since it's software based.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json
new file mode 100644
index 00000000000..e48f8505277
--- /dev/null
+++ b/homeassistant/components/portainer/strings.json
@@ -0,0 +1,66 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "api_token": "[%key:common::config_flow::data::api_token%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "url": "The URL, including the port, of your Portainer instance",
+ "api_token": "The API access token for authenticating with Portainer",
+ "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate"
+ },
+ "description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
+ },
+ "reauth_confirm": {
+ "data": {
+ "api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "The new API access token for authenticating with Portainer"
+ },
+ "description": "The access token for your Portainer instance needs to be re-authenticated. You can create a new access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**"
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "device": {
+ "unknown_container": {
+ "name": "Unknown container"
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "status": {
+ "name": "Status"
+ }
+ },
+ "switch": {
+ "container": {
+ "name": "Container"
+ }
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "An error occurred while trying to connect to the Portainer instance: {error}"
+ },
+ "invalid_auth": {
+ "message": "An error occurred while trying to authenticate: {error}"
+ },
+ "timeout_connect": {
+ "message": "A timeout occurred while trying to connect to the Portainer instance: {error}"
+ }
+ }
+}
diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py
new file mode 100644
index 00000000000..eed33e43c0c
--- /dev/null
+++ b/homeassistant/components/portainer/switch.py
@@ -0,0 +1,141 @@
+"""Switch platform for Portainer containers."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any
+
+from pyportainer import Portainer
+from pyportainer.exceptions import (
+ PortainerAuthenticationError,
+ PortainerConnectionError,
+ PortainerTimeoutError,
+)
+from pyportainer.models.docker import DockerContainer
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PortainerConfigEntry
+from .const import DOMAIN
+from .coordinator import PortainerCoordinator
+from .entity import PortainerContainerEntity, PortainerCoordinatorData
+
+
+@dataclass(frozen=True, kw_only=True)
+class PortainerSwitchEntityDescription(SwitchEntityDescription):
+ """Class to hold Portainer switch description."""
+
+ is_on_fn: Callable[[DockerContainer], bool | None]
+ turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
+ turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
+
+
+async def perform_action(
+ action: str, portainer: Portainer, endpoint_id: int, container_id: str
+) -> None:
+ """Stop 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)
+ except PortainerAuthenticationError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except PortainerTimeoutError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="timeout_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+
+
+SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
+ PortainerSwitchEntityDescription(
+ key="container",
+ translation_key="container",
+ device_class=SwitchDeviceClass.SWITCH,
+ is_on_fn=lambda data: data.state == "running",
+ turn_on_fn=perform_action,
+ turn_off_fn=perform_action,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PortainerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Portainer switch sensors."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ PortainerContainerSwitch(
+ coordinator=coordinator,
+ entity_description=entity_description,
+ device_info=container,
+ via_device=endpoint,
+ )
+ for endpoint in coordinator.data.values()
+ for container in endpoint.containers.values()
+ for entity_description in SWITCHES
+ )
+
+
+class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
+ """Representation of a Portainer container switch."""
+
+ entity_description: PortainerSwitchEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PortainerCoordinator,
+ entity_description: PortainerSwitchEntityDescription,
+ device_info: DockerContainer,
+ via_device: PortainerCoordinatorData,
+ ) -> None:
+ """Initialize the Portainer container switch."""
+ self.entity_description = entity_description
+ super().__init__(device_info, coordinator, via_device)
+
+ self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the state of the device."""
+ return self.entity_description.is_on_fn(
+ self.coordinator.data[self.endpoint_id].containers[self.device_id]
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Start (turn on) the container."""
+ await self.entity_description.turn_on_fn(
+ "start", self.coordinator.portainer, self.endpoint_id, self.device_id
+ )
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Stop (turn off) the container."""
+ await self.entity_description.turn_off_fn(
+ "stop", self.coordinator.portainer, self.endpoint_id, self.device_id
+ )
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json
index 439e44faad1..0d3c16f5b76 100644
--- a/homeassistant/components/private_ble_device/manifest.json
+++ b/homeassistant/components/private_ble_device/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.28.2"]
+ "requirements": ["bluetooth-data-tools==1.28.3"]
}
diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py
index 749b73e5aee..66b35eaff21 100644
--- a/homeassistant/components/profiler/__init__.py
+++ b/homeassistant/components/profiler/__init__.py
@@ -35,6 +35,7 @@ SERVICE_STOP_LOG_OBJECTS = "stop_log_objects"
SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources"
SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources"
SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects"
+SERVICE_DUMP_SOCKETS = "dump_sockets"
SERVICE_LRU_STATS = "lru_stats"
SERVICE_LOG_THREAD_FRAMES = "log_thread_frames"
SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled"
@@ -231,6 +232,15 @@ async def async_setup_entry( # noqa: C901
notification_id="profile_lru_stats",
)
+ def _dump_sockets(call: ServiceCall) -> None:
+ """Dump list of all currently existing sockets to the log."""
+ import objgraph # noqa: PLC0415
+
+ _LOGGER.critical(
+ "Sockets used by Home Assistant:\n%s",
+ "\n".join(repr(sock) for sock in objgraph.by_type("socket")),
+ )
+
async def _async_dump_thread_frames(call: ServiceCall) -> None:
"""Log all thread frames."""
frames = sys._current_frames() # noqa: SLF001
@@ -346,6 +356,13 @@ async def async_setup_entry( # noqa: C901
schema=vol.Schema({vol.Required(CONF_TYPE): str}),
)
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_DUMP_SOCKETS,
+ _dump_sockets,
+ )
+
async_register_admin_service(
hass,
DOMAIN,
diff --git a/homeassistant/components/profiler/icons.json b/homeassistant/components/profiler/icons.json
index c1f996b6eb1..0c6a4f2600a 100644
--- a/homeassistant/components/profiler/icons.json
+++ b/homeassistant/components/profiler/icons.json
@@ -15,6 +15,9 @@
"dump_log_objects": {
"service": "mdi:invoice-export-outline"
},
+ "dump_sockets": {
+ "service": "mdi:pipe"
+ },
"start_log_object_sources": {
"service": "mdi:play"
},
diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml
index 82cdcf8d96e..d0b8cc09832 100644
--- a/homeassistant/components/profiler/services.yaml
+++ b/homeassistant/components/profiler/services.yaml
@@ -51,6 +51,7 @@ start_log_object_sources:
unit_of_measurement: objects
stop_log_object_sources:
lru_stats:
+dump_sockets:
log_thread_frames:
log_event_loop_scheduled:
set_asyncio_debug:
diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json
index f363b5a22cb..ccbf42bb46b 100644
--- a/homeassistant/components/profiler/strings.json
+++ b/homeassistant/components/profiler/strings.json
@@ -65,6 +65,10 @@
}
}
},
+ "dump_sockets": {
+ "name": "Dump used sockets",
+ "description": "Logs information about all currently used sockets."
+ },
"stop_log_object_sources": {
"name": "Stop logging object sources",
"description": "Stops logging sources of new objects in memory."
diff --git a/homeassistant/components/prowl/const.py b/homeassistant/components/prowl/const.py
new file mode 100644
index 00000000000..7037e29da73
--- /dev/null
+++ b/homeassistant/components/prowl/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Prowl Notification service."""
+
+DOMAIN = "prowl"
diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json
index 049d95fb94c..b97e6510238 100644
--- a/homeassistant/components/prowl/manifest.json
+++ b/homeassistant/components/prowl/manifest.json
@@ -3,6 +3,9 @@
"name": "Prowl",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/prowl",
+ "integration_type": "service",
"iot_class": "cloud_push",
- "quality_scale": "legacy"
+ "loggers": ["prowl"],
+ "quality_scale": "legacy",
+ "requirements": ["prowlpy==1.0.2"]
}
diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py
index e9d2bbde4e5..e236230ec5b 100644
--- a/homeassistant/components/prowl/notify.py
+++ b/homeassistant/components/prowl/notify.py
@@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
-from http import HTTPStatus
+from functools import partial
import logging
+from typing import Any
+import prowlpy
import voluptuous as vol
from homeassistant.components.notify import (
@@ -17,12 +19,11 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
-_RESOURCE = "https://api.prowlapp.com/publicapi/"
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string})
@@ -33,46 +34,49 @@ async def async_get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> ProwlNotificationService:
"""Get the Prowl notification service."""
- return ProwlNotificationService(hass, config[CONF_API_KEY])
+ prowl = await hass.async_add_executor_job(
+ partial(prowlpy.Prowl, apikey=config[CONF_API_KEY])
+ )
+ return ProwlNotificationService(hass, prowl)
class ProwlNotificationService(BaseNotificationService):
"""Implement the notification service for Prowl."""
- def __init__(self, hass, api_key):
+ def __init__(self, hass: HomeAssistant, prowl: prowlpy.Prowl) -> None:
"""Initialize the service."""
self._hass = hass
- self._api_key = api_key
+ self._prowl = prowl
- 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."""
- response = None
- session = None
- url = f"{_RESOURCE}add"
- data = kwargs.get(ATTR_DATA)
- payload = {
- "apikey": self._api_key,
- "application": "Home-Assistant",
- "event": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
- "description": message,
- "priority": data["priority"] if data and "priority" in data else 0,
- }
- if data and data.get("url"):
- payload["url"] = data["url"]
-
- _LOGGER.debug("Attempting call Prowl service at %s", url)
- session = async_get_clientsession(self._hass)
+ data = kwargs.get(ATTR_DATA, {})
+ if data is None:
+ data = {}
try:
async with asyncio.timeout(10):
- response = await session.post(url, data=payload)
- result = await response.text()
-
- if response.status != HTTPStatus.OK or "error" in result:
- _LOGGER.error(
- "Prowl service returned http status %d, response %s",
- response.status,
- result,
+ await self._hass.async_add_executor_job(
+ partial(
+ self._prowl.send,
+ application="Home-Assistant",
+ event=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
+ description=message,
+ priority=data.get("priority", 0),
+ url=data.get("url"),
+ )
)
- except TimeoutError:
- _LOGGER.error("Timeout accessing Prowl at %s", url)
+ except TimeoutError as ex:
+ _LOGGER.error("Timeout accessing Prowl API")
+ raise HomeAssistantError("Timeout accessing Prowl API") from ex
+ except prowlpy.APIError as ex:
+ if str(ex).startswith("Invalid API key"):
+ _LOGGER.error("Invalid API key for Prowl service")
+ raise HomeAssistantError("Invalid API key for Prowl service") from ex
+ if str(ex).startswith("Not accepted"):
+ _LOGGER.error("Prowl returned: exceeded rate limit")
+ raise HomeAssistantError(
+ "Prowl service reported: exceeded rate limit"
+ ) from ex
+ _LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex))
+ raise HomeAssistantError("Unexpected error when calling Prowl API") from ex
diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py
index 11fa530f47b..00b39957984 100644
--- a/homeassistant/components/proxmoxve/__init__.py
+++ b/homeassistant/components/proxmoxve/__init__.py
@@ -215,6 +215,7 @@ def create_coordinator_container_vm(
return DataUpdateCoordinator(
hass,
_LOGGER,
+ config_entry=None,
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py
index e6f54bc6fa5..8d994fa728a 100644
--- a/homeassistant/components/prusalink/coordinator.py
+++ b/homeassistant/components/prusalink/coordinator.py
@@ -21,12 +21,16 @@ from pyprusalink.types import InvalidAuth, PrusaLinkError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+# Allow automations using homeassistant.update_entity to collect
+# rapidly-changing metrics.
+_MINIMUM_REFRESH_INTERVAL = 1.0
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo)
@@ -49,6 +53,9 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC):
config_entry=config_entry,
name=DOMAIN,
update_interval=self._get_update_interval(None),
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=_MINIMUM_REFRESH_INTERVAL, immediate=True
+ ),
)
async def _async_update_data(self) -> T:
diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json
index c41b55bd5ab..a6ed92d08d8 100644
--- a/homeassistant/components/prusalink/manifest.json
+++ b/homeassistant/components/prusalink/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "prusalink",
"name": "PrusaLink",
- "codeowners": ["@balloob"],
+ "codeowners": [],
"config_flow": true,
"dhcp": [
{
diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py
index 4ed0c0340c6..1d51e402ef4 100644
--- a/homeassistant/components/purpleair/coordinator.py
+++ b/homeassistant/components/purpleair/coordinator.py
@@ -43,7 +43,7 @@ SENSOR_FIELDS_TO_RETRIEVE = [
"voc",
]
-UPDATE_INTERVAL = timedelta(minutes=2)
+UPDATE_INTERVAL = timedelta(minutes=5)
type PurpleAirConfigEntry = ConfigEntry[PurpleAirDataUpdateCoordinator]
diff --git a/homeassistant/components/purpleair/manifest.json b/homeassistant/components/purpleair/manifest.json
index 87cb375c347..a1cebb289c9 100644
--- a/homeassistant/components/purpleair/manifest.json
+++ b/homeassistant/components/purpleair/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/purpleair",
"iot_class": "cloud_polling",
- "requirements": ["aiopurpleair==2023.12.0"]
+ "requirements": ["aiopurpleair==2025.08.1"]
}
diff --git a/homeassistant/components/pushover/const.py b/homeassistant/components/pushover/const.py
index af541132297..eccd3e9e182 100644
--- a/homeassistant/components/pushover/const.py
+++ b/homeassistant/components/pushover/const.py
@@ -15,6 +15,7 @@ ATTR_SOUND: Final = "sound"
ATTR_HTML: Final = "html"
ATTR_CALLBACK_URL: Final = "callback_url"
ATTR_EXPIRE: Final = "expire"
+ATTR_TTL: Final = "ttl"
ATTR_TIMESTAMP: Final = "timestamp"
CONF_USER_KEY: Final = "user_key"
diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py
index 34ee1d08bdd..62c14b4dae8 100644
--- a/homeassistant/components/pushover/notify.py
+++ b/homeassistant/components/pushover/notify.py
@@ -27,6 +27,7 @@ from .const import (
ATTR_RETRY,
ATTR_SOUND,
ATTR_TIMESTAMP,
+ ATTR_TTL,
ATTR_URL,
ATTR_URL_TITLE,
CONF_USER_KEY,
@@ -66,12 +67,13 @@ class PushoverNotificationService(BaseNotificationService):
# Extract params from data dict
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = dict(kwargs.get(ATTR_DATA) or {})
+ data = kwargs.get(ATTR_DATA) or {}
url = data.get(ATTR_URL)
url_title = data.get(ATTR_URL_TITLE)
priority = data.get(ATTR_PRIORITY)
retry = data.get(ATTR_RETRY)
expire = data.get(ATTR_EXPIRE)
+ ttl = data.get(ATTR_TTL)
callback_url = data.get(ATTR_CALLBACK_URL)
timestamp = data.get(ATTR_TIMESTAMP)
sound = data.get(ATTR_SOUND)
@@ -98,20 +100,21 @@ class PushoverNotificationService(BaseNotificationService):
try:
self.pushover.send_message(
- self._user_key,
- message,
- ",".join(kwargs.get(ATTR_TARGET, [])),
- title,
- url,
- url_title,
- image,
- priority,
- retry,
- expire,
- callback_url,
- timestamp,
- sound,
- html,
+ user=self._user_key,
+ message=message,
+ device=",".join(kwargs.get(ATTR_TARGET, [])),
+ title=title,
+ url=url,
+ url_title=url_title,
+ image=image,
+ priority=priority,
+ retry=retry,
+ expire=expire,
+ callback_url=callback_url,
+ timestamp=timestamp,
+ sound=sound,
+ html=html,
+ ttl=ttl,
)
except BadAPIRequestError as err:
raise HomeAssistantError(str(err)) from err
diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py
index ad35e409627..5ea4d65596d 100644
--- a/homeassistant/components/pvpc_hourly_pricing/__init__.py
+++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py
@@ -4,7 +4,6 @@ from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .const import ATTR_POWER, ATTR_POWER_P3
from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry
from .helpers import get_enabled_sensor_keys
@@ -23,23 +22,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
-async def async_update_options(hass: HomeAssistant, entry: PVPCConfigEntry) -> None:
- """Handle options update."""
- if any(
- entry.data.get(attrib) != entry.options.get(attrib)
- for attrib in (ATTR_POWER, ATTR_POWER_P3, CONF_API_TOKEN)
- ):
- # update entry replacing data with new options
- hass.config_entries.async_update_entry(
- entry, data={**entry.data, **entry.options}
- )
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py
index 3c6b510004a..2efb9cad939 100644
--- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py
+++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
- OptionsFlow,
+ OptionsFlowWithReload,
)
from homeassistant.const import CONF_API_TOKEN, CONF_NAME
from homeassistant.core import callback
@@ -178,7 +178,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema)
-class PVPCOptionsFlowHandler(OptionsFlow):
+class PVPCOptionsFlowHandler(OptionsFlowWithReload):
"""Handle PVPC options."""
_power: float | None = None
diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py
index bc9d6a21557..c357551be8f 100644
--- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py
+++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py
@@ -29,13 +29,16 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]):
self, hass: HomeAssistant, entry: PVPCConfigEntry, sensor_keys: set[str]
) -> None:
"""Initialize."""
+ config = entry.data.copy()
+ config.update({attr: value for attr, value in entry.options.items() if value})
+
self.api = PVPCData(
session=async_get_clientsession(hass),
- tariff=entry.data[ATTR_TARIFF],
+ tariff=config[ATTR_TARIFF],
local_timezone=hass.config.time_zone,
- power=entry.data[ATTR_POWER],
- power_valley=entry.data[ATTR_POWER_P3],
- api_token=entry.data.get(CONF_API_TOKEN),
+ power=config[ATTR_POWER],
+ power_valley=config[ATTR_POWER_P3],
+ api_token=config.get(CONF_API_TOKEN),
sensor_keys=tuple(sensor_keys),
)
super().__init__(
diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
index d565d2f7b5f..efdab3122f5 100644
--- a/homeassistant/components/qbittorrent/sensor.py
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -39,6 +39,7 @@ SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents"
SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
+SENSOR_TYPE_ERRORED_TORRENTS = "errored_torrents"
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
@@ -221,6 +222,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
coordinator, ["stoppedDL", "stoppedUP"]
),
),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_ERRORED_TORRENTS,
+ translation_key="errored_torrents",
+ value_fn=lambda coordinator: count_torrents_in_states(
+ coordinator, ["error", "missingFiles"]
+ ),
+ ),
)
diff --git a/homeassistant/components/qbittorrent/services.yaml b/homeassistant/components/qbittorrent/services.yaml
index f7fc6b95f64..fc94e80f358 100644
--- a/homeassistant/components/qbittorrent/services.yaml
+++ b/homeassistant/components/qbittorrent/services.yaml
@@ -18,6 +18,7 @@ get_torrents:
- "all"
- "seeding"
- "started"
+ - "errored"
get_all_torrents:
fields:
torrent_filter:
@@ -33,3 +34,4 @@ get_all_torrents:
- "all"
- "seeding"
- "started"
+ - "errored"
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index ef2f45bbc28..d392e081b71 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -70,6 +70,10 @@
"name": "Paused torrents",
"unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
},
+ "errored_torrents": {
+ "name": "Errored torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
+ },
"all_torrents": {
"name": "All torrents",
"unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py
index f7205a85c00..784af0594fb 100644
--- a/homeassistant/components/qbus/entity.py
+++ b/homeassistant/components/qbus/entity.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import re
-from typing import Generic, TypeVar, cast
+from typing import cast
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
@@ -20,8 +20,6 @@ from .coordinator import QbusControllerCoordinator
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
-StateT = TypeVar("StateT", bound=QbusMqttState)
-
def create_new_entities(
coordinator: QbusControllerCoordinator,
@@ -78,7 +76,7 @@ def create_unique_id(serial_number: str, suffix: str) -> str:
return f"ctd_{serial_number}_{suffix}"
-class QbusEntity(Entity, Generic[StateT], ABC):
+class QbusEntity[StateT: QbusMqttState](Entity, ABC):
"""Representation of a Qbus entity."""
_state_cls: type[StateT] = cast(type[StateT], QbusMqttState)
diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py
index 706fb089dde..4403fe28259 100644
--- a/homeassistant/components/qbus/scene.py
+++ b/homeassistant/components/qbus/scene.py
@@ -5,7 +5,7 @@ from typing import Any
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttState, StateAction, StateType
-from homeassistant.components.scene import Scene
+from homeassistant.components.scene import BaseScene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -38,7 +38,7 @@ async def async_setup_entry(
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
-class QbusScene(QbusEntity, Scene):
+class QbusScene(QbusEntity, BaseScene):
"""Representation of a Qbus scene entity."""
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
@@ -48,7 +48,7 @@ class QbusScene(QbusEntity, Scene):
self._attr_name = mqtt_output.name.title()
- async def async_activate(self, **kwargs: Any) -> None:
+ async def _async_activate(self, **kwargs: Any) -> None:
"""Activate scene."""
state = QbusMqttState(
id=self._mqtt_output.id, type=StateType.ACTION, action=StateAction.ACTIVE
@@ -56,5 +56,4 @@ class QbusScene(QbusEntity, Scene):
await self._async_publish_output_state(state)
async def _handle_state_received(self, state: QbusMqttState) -> None:
- # Nothing to do
- pass
+ self._async_record_activation()
diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json
index e0317ab89b5..11d408dab42 100644
--- a/homeassistant/components/qingping/manifest.json
+++ b/homeassistant/components/qingping/manifest.json
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/qingping",
"iot_class": "local_push",
- "requirements": ["qingping-ble==0.10.0"]
+ "requirements": ["qingping-ble==1.0.1"]
}
diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py
index d343675d7ea..72789658649 100644
--- a/homeassistant/components/radarr/coordinator.py
+++ b/homeassistant/components/radarr/coordinator.py
@@ -57,7 +57,7 @@ class RadarrEvent(CalendarEvent, RadarrEventMixIn):
"""A class to describe a Radarr calendar event."""
-class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC):
+class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]):
"""Data update coordinator for the Radarr integration."""
config_entry: RadarrConfigEntry
diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py
index dc91525677b..e62fe0325cc 100644
--- a/homeassistant/components/radio_browser/media_source.py
+++ b/homeassistant/components/radio_browser/media_source.py
@@ -16,6 +16,7 @@ from homeassistant.components.media_source import (
Unresolvable,
)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.util.location import vincenty
from . import RadioBrowserConfigEntry
from .const import DOMAIN
@@ -66,7 +67,7 @@ class RadioMediaSource(MediaSource):
# Register "click" with Radio Browser
await radios.station_click(uuid=station.uuid)
- return PlayMedia(station.url, mime_type)
+ return PlayMedia(station.url_resolved, mime_type)
async def async_browse_media(
self,
@@ -88,6 +89,7 @@ class RadioMediaSource(MediaSource):
*await self._async_build_popular(radios, item),
*await self._async_build_by_tag(radios, item),
*await self._async_build_by_language(radios, item),
+ *await self._async_build_local(radios, item),
*await self._async_build_by_country(radios, item),
],
)
@@ -134,7 +136,7 @@ class RadioMediaSource(MediaSource):
) -> list[BrowseMediaSource]:
"""Handle browsing radio stations by country."""
category, _, country_code = (item.identifier or "").partition("/")
- if country_code:
+ if category == "country" and country_code:
stations = await radios.stations(
filter_by=FilterBy.COUNTRY_CODE_EXACT,
filter_term=country_code,
@@ -185,7 +187,7 @@ class RadioMediaSource(MediaSource):
return [
BrowseMediaSource(
domain=DOMAIN,
- identifier=f"language/{language.code}",
+ identifier=f"language/{language.name.lower()}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
title=language.name,
@@ -292,3 +294,63 @@ class RadioMediaSource(MediaSource):
]
return []
+
+ def _filter_local_stations(
+ self, stations: list[Station], latitude: float, longitude: float
+ ) -> list[Station]:
+ return [
+ station
+ for station in stations
+ if station.latitude is not None
+ and station.longitude is not None
+ and (
+ (
+ dist := vincenty(
+ (latitude, longitude),
+ (station.latitude, station.longitude),
+ False,
+ )
+ )
+ is not None
+ )
+ and dist < 100
+ ]
+
+ async def _async_build_local(
+ self, radios: RadioBrowser, item: MediaSourceItem
+ ) -> list[BrowseMediaSource]:
+ """Handle browsing local radio stations."""
+
+ if item.identifier == "local":
+ country = self.hass.config.country
+ stations = await radios.stations(
+ filter_by=FilterBy.COUNTRY_CODE_EXACT,
+ filter_term=country,
+ hide_broken=True,
+ order=Order.NAME,
+ reverse=False,
+ )
+
+ local_stations = await self.hass.async_add_executor_job(
+ self._filter_local_stations,
+ stations,
+ self.hass.config.latitude,
+ self.hass.config.longitude,
+ )
+
+ return self._async_build_stations(radios, local_stations)
+
+ if not item.identifier:
+ return [
+ BrowseMediaSource(
+ domain=DOMAIN,
+ identifier="local",
+ media_class=MediaClass.DIRECTORY,
+ media_content_type=MediaType.MUSIC,
+ title="Local stations",
+ can_play=False,
+ can_expand=True,
+ )
+ ]
+
+ return []
diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py
index bff2ce53dfb..28569e49c26 100644
--- a/homeassistant/components/random/__init__.py
+++ b/homeassistant/components/random/__init__.py
@@ -9,15 +9,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["entity_type"],)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py
index 406100388e6..c709b75f490 100644
--- a/homeassistant/components/random/config_flow.py
+++ b/homeassistant/components/random/config_flow.py
@@ -184,6 +184,7 @@ class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json
index 450f78f9e83..bf83da70de1 100644
--- a/homeassistant/components/random/strings.json
+++ b/homeassistant/components/random/strings.json
@@ -114,6 +114,7 @@
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py
index 4797eecda0f..b1563d85d56 100644
--- a/homeassistant/components/recorder/const.py
+++ b/homeassistant/components/recorder/const.py
@@ -53,7 +53,6 @@ KEEPALIVE_TIME = 30
CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
-LAST_REPORTED_SCHEMA_VERSION = 43
CIRCULAR_MEAN_SCHEMA_VERSION = 49
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index 34fa6a62d44..d662416012f 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -56,7 +56,6 @@ from .const import (
DEFAULT_MAX_BIND_VARS,
DOMAIN,
KEEPALIVE_TIME,
- LAST_REPORTED_SCHEMA_VERSION,
MARIADB_PYMYSQL_URL_PREFIX,
MARIADB_URL_PREFIX,
MAX_QUEUE_BACKLOG_MIN_VALUE,
@@ -806,6 +805,10 @@ class Recorder(threading.Thread):
# Catch up with missed statistics
self._schedule_compile_missing_statistics()
+
+ # Kick off live migrations
+ migration.migrate_data_live(self, self.get_session, schema_status)
+
_LOGGER.debug("Recorder processing the queue")
self._adjust_lru_size()
self.hass.add_job(self._async_set_recorder_ready_migration_done)
@@ -822,8 +825,6 @@ class Recorder(threading.Thread):
# there are a lot of statistics graphs on the frontend.
self.statistics_meta_manager.load(session)
- migration.migrate_data_live(self, self.get_session, schema_status)
-
# We must only set the db ready after we have set the table managers
# to active if there is no data to migrate.
#
@@ -1127,9 +1128,6 @@ class Recorder(threading.Thread):
else:
states_manager.add_pending(entity_id, dbstate)
- if states_meta_manager.active:
- dbstate.entity_id = None
-
if entity_id is None or not (
shared_attrs_bytes := state_attributes_manager.serialize_from_event(event)
):
@@ -1140,7 +1138,7 @@ class Recorder(threading.Thread):
dbstate.states_meta_rel = pending_states_meta
elif metadata_id := states_meta_manager.get(entity_id, session, True):
dbstate.metadata_id = metadata_id
- elif states_meta_manager.active and entity_removed:
+ elif entity_removed:
# If the entity was removed, we don't need to add it to the
# StatesMeta table or record it in the pending commit
# if it does not have a metadata_id allocated to it as
@@ -1227,7 +1225,7 @@ class Recorder(threading.Thread):
if (
pending_last_reported
:= self.states_manager.get_pending_last_reported_timestamp()
- ) and self.schema_version >= LAST_REPORTED_SCHEMA_VERSION:
+ ):
with session.no_autoflush:
session.execute(
update(States),
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index 6566cadf64c..a0e82de9fe0 100644
--- a/homeassistant/components/recorder/db_schema.py
+++ b/homeassistant/components/recorder/db_schema.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from datetime import datetime, timedelta
import logging
import time
-from typing import Any, Final, Protocol, Self, cast
+from typing import Any, Final, Protocol, Self
import ciso8601
from fnv_hash_fast import fnv1a_32
@@ -45,14 +45,9 @@ from homeassistant.const import (
MAX_LENGTH_STATE_ENTITY_ID,
MAX_LENGTH_STATE_STATE,
)
-from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State
+from homeassistant.core import Event, EventStateChangedData
from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null
from homeassistant.util import dt as dt_util
-from homeassistant.util.json import (
- JSON_DECODE_EXCEPTIONS,
- json_loads,
- json_loads_object,
-)
from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
from .models import (
@@ -60,8 +55,6 @@ from .models import (
StatisticDataTimestamp,
StatisticMeanType,
StatisticMetaData,
- bytes_to_ulid_or_none,
- bytes_to_uuid_hex_or_none,
datetime_to_timestamp_or_none,
process_timestamp,
ulid_to_bytes_or_none,
@@ -251,9 +244,6 @@ class JSONLiteral(JSON):
return process
-EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote]
-
-
class Events(Base):
"""Event history data."""
@@ -333,28 +323,6 @@ class Events(Base):
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
)
- def to_native(self, validate_entity_id: bool = True) -> Event | None:
- """Convert to a native HA Event."""
- context = Context(
- id=bytes_to_ulid_or_none(self.context_id_bin),
- user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin),
- parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin),
- )
- try:
- return Event(
- self.event_type or "",
- json_loads_object(self.event_data) if self.event_data else {},
- EventOrigin(self.origin)
- if self.origin
- else EVENT_ORIGIN_ORDER[self.origin_idx or 0],
- self.time_fired_ts or 0,
- context=context,
- )
- except JSON_DECODE_EXCEPTIONS:
- # When json_loads fails
- _LOGGER.exception("Error converting to event: %s", self)
- return None
-
class LegacyEvents(LegacyBase):
"""Event history data with event_id, used for schema migration."""
@@ -410,17 +378,6 @@ class EventData(Base):
"""Return the hash of json encoded shared data."""
return fnv1a_32(shared_data_bytes)
- def to_native(self) -> dict[str, Any]:
- """Convert to an event data dictionary."""
- shared_data = self.shared_data
- if shared_data is None:
- return {}
- try:
- return cast(dict[str, Any], json_loads(shared_data))
- except JSON_DECODE_EXCEPTIONS:
- _LOGGER.exception("Error converting row to event data: %s", self)
- return {}
-
class EventTypes(Base):
"""Event type history."""
@@ -537,7 +494,7 @@ class States(Base):
context = event.context
return States(
state=state_value,
- entity_id=event.data["entity_id"],
+ entity_id=None,
attributes=None,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
@@ -553,44 +510,6 @@ class States(Base):
last_reported_ts=last_reported_ts,
)
- def to_native(self, validate_entity_id: bool = True) -> State | None:
- """Convert to an HA state object."""
- context = Context(
- id=bytes_to_ulid_or_none(self.context_id_bin),
- user_id=bytes_to_uuid_hex_or_none(self.context_user_id_bin),
- parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin),
- )
- try:
- attrs = json_loads_object(self.attributes) if self.attributes else {}
- except JSON_DECODE_EXCEPTIONS:
- # When json_loads fails
- _LOGGER.exception("Error converting row to state: %s", self)
- return None
- last_updated = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
- if self.last_changed_ts is None or self.last_changed_ts == self.last_updated_ts:
- last_changed = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
- else:
- last_changed = dt_util.utc_from_timestamp(self.last_changed_ts or 0)
- if (
- self.last_reported_ts is None
- or self.last_reported_ts == self.last_updated_ts
- ):
- last_reported = dt_util.utc_from_timestamp(self.last_updated_ts or 0)
- else:
- last_reported = dt_util.utc_from_timestamp(self.last_reported_ts or 0)
- return State(
- self.entity_id or "",
- self.state, # type: ignore[arg-type]
- # Join the state_attributes table on attributes_id to get the attributes
- # for newer states
- attrs,
- last_changed=last_changed,
- last_reported=last_reported,
- last_updated=last_updated,
- context=context,
- validate_entity_id=validate_entity_id,
- )
-
class LegacyStates(LegacyBase):
"""State change history with entity_id, used for schema migration."""
@@ -675,18 +594,6 @@ class StateAttributes(Base):
"""Return the hash of json encoded shared attributes."""
return fnv1a_32(shared_attrs_bytes)
- def to_native(self) -> dict[str, Any]:
- """Convert to a state attributes dictionary."""
- shared_attrs = self.shared_attrs
- if shared_attrs is None:
- return {}
- try:
- return cast(dict[str, Any], json_loads(shared_attrs))
- except JSON_DECODE_EXCEPTIONS:
- # When json_loads fails
- _LOGGER.exception("Error converting row to state attributes: %s", self)
- return {}
-
class StatesMeta(Base):
"""Metadata for states."""
@@ -903,10 +810,6 @@ class RecorderRuns(Base):
f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>"
)
- def to_native(self, validate_entity_id: bool = True) -> Self:
- """Return self, native format is this model."""
- return self
-
class MigrationChanges(Base):
"""Representation of migration changes."""
diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py
index 30a3a1b8239..904582b75f0 100644
--- a/homeassistant/components/recorder/entity_registry.py
+++ b/homeassistant/components/recorder/entity_registry.py
@@ -61,15 +61,6 @@ def update_states_metadata(
) -> None:
"""Update the states metadata table when an entity is renamed."""
states_meta_manager = instance.states_meta_manager
- if not states_meta_manager.active:
- _LOGGER.warning(
- "Cannot rename entity_id `%s` to `%s` "
- "because the states meta manager is not yet active",
- entity_id,
- new_entity_id,
- )
- return
-
with session_scope(
session=instance.get_session(),
exception_filter=filter_unique_constraint_integrity_error(instance, "state"),
diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py
index 469d6694640..32e0b4f9a71 100644
--- a/homeassistant/components/recorder/history/__init__.py
+++ b/homeassistant/components/recorder/history/__init__.py
@@ -2,22 +2,53 @@
from __future__ import annotations
+from collections.abc import Callable, Iterable, Iterator
from datetime import datetime
-from typing import Any
+from itertools import groupby
+from operator import itemgetter
+from typing import TYPE_CHECKING, Any, cast
+from sqlalchemy import (
+ CompoundSelect,
+ Select,
+ StatementLambdaElement,
+ Subquery,
+ and_,
+ func,
+ lambda_stmt,
+ literal,
+ select,
+ union_all,
+)
+from sqlalchemy.engine.row import Row
from sqlalchemy.orm.session import Session
-from homeassistant.core import HomeAssistant, State
+from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
+from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
+from homeassistant.util import dt as dt_util
+from homeassistant.util.collection import chunked_or_all
+from ..const import MAX_IDS_FOR_INDEXED_GROUP_BY
+from ..db_schema import (
+ SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
+ StateAttributes,
+ States,
+ StatesMeta,
+)
from ..filters import Filters
-from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS
-from .modern import (
- get_full_significant_states_with_session as _modern_get_full_significant_states_with_session,
- get_last_state_changes as _modern_get_last_state_changes,
- get_significant_states as _modern_get_significant_states,
- get_significant_states_with_session as _modern_get_significant_states_with_session,
- state_changes_during_period as _modern_state_changes_during_period,
+from ..models import (
+ LazyState,
+ datetime_to_timestamp_or_none,
+ extract_metadata_ids,
+ row_to_compressed_state,
+)
+from ..util import execute_stmt_lambda_element, session_scope
+from .const import (
+ LAST_CHANGED_KEY,
+ NEED_ATTRIBUTE_DOMAINS,
+ SIGNIFICANT_DOMAINS,
+ STATE_KEY,
)
# These are the APIs of this package
@@ -31,53 +62,65 @@ __all__ = [
"state_changes_during_period",
]
+_FIELD_MAP = {
+ "metadata_id": 0,
+ "state": 1,
+ "last_updated_ts": 2,
+}
-def get_full_significant_states_with_session(
- hass: HomeAssistant,
- session: Session,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- no_attributes: bool = False,
-) -> dict[str, list[State]]:
- """Return a dict of significant states during a time period."""
- if not get_instance(hass).states_meta_manager.active:
- from .legacy import ( # noqa: PLC0415
- get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session,
- )
- _target = _legacy_get_full_significant_states_with_session
- else:
- _target = _modern_get_full_significant_states_with_session
- return _target(
- hass,
- session,
- start_time,
- end_time,
- entity_ids,
- filters,
- include_start_time_state,
- significant_changes_only,
- no_attributes,
+def _stmt_and_join_attributes(
+ no_attributes: bool,
+ include_last_changed: bool,
+ include_last_reported: bool,
+) -> Select:
+ """Return the statement and if StateAttributes should be joined."""
+ _select = select(States.metadata_id, States.state, States.last_updated_ts)
+ if include_last_changed:
+ _select = _select.add_columns(States.last_changed_ts)
+ if include_last_reported:
+ _select = _select.add_columns(States.last_reported_ts)
+ if not no_attributes:
+ _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES)
+ return _select
+
+
+def _stmt_and_join_attributes_for_start_state(
+ no_attributes: bool,
+ include_last_changed: bool,
+ include_last_reported: bool,
+) -> Select:
+ """Return the statement and if StateAttributes should be joined."""
+ _select = select(States.metadata_id, States.state)
+ _select = _select.add_columns(literal(value=0).label("last_updated_ts"))
+ if include_last_changed:
+ _select = _select.add_columns(literal(value=0).label("last_changed_ts"))
+ if include_last_reported:
+ _select = _select.add_columns(literal(value=0).label("last_reported_ts"))
+ if not no_attributes:
+ _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES)
+ return _select
+
+
+def _select_from_subquery(
+ subquery: Subquery | CompoundSelect,
+ no_attributes: bool,
+ include_last_changed: bool,
+ include_last_reported: bool,
+) -> Select:
+ """Return the statement to select from the union."""
+ base_select = select(
+ subquery.c.metadata_id,
+ subquery.c.state,
+ subquery.c.last_updated_ts,
)
-
-
-def get_last_state_changes(
- hass: HomeAssistant, number_of_states: int, entity_id: str
-) -> dict[str, list[State]]:
- """Return the last number_of_states."""
- if not get_instance(hass).states_meta_manager.active:
- from .legacy import ( # noqa: PLC0415
- get_last_state_changes as _legacy_get_last_state_changes,
- )
-
- _target = _legacy_get_last_state_changes
- else:
- _target = _modern_get_last_state_changes
- return _target(hass, number_of_states, entity_id)
+ if include_last_changed:
+ base_select = base_select.add_columns(subquery.c.last_changed_ts)
+ if include_last_reported:
+ base_select = base_select.add_columns(subquery.c.last_reported_ts)
+ if no_attributes:
+ return base_select
+ return base_select.add_columns(subquery.c.attributes)
def get_significant_states(
@@ -92,27 +135,88 @@ def get_significant_states(
no_attributes: bool = False,
compressed_state_format: bool = False,
) -> dict[str, list[State | dict[str, Any]]]:
- """Return a dict of significant states during a time period."""
- if not get_instance(hass).states_meta_manager.active:
- from .legacy import ( # noqa: PLC0415
- get_significant_states as _legacy_get_significant_states,
+ """Wrap get_significant_states_with_session with an sql session."""
+ with session_scope(hass=hass, read_only=True) as session:
+ return get_significant_states_with_session(
+ hass,
+ session,
+ start_time,
+ end_time,
+ entity_ids,
+ filters,
+ include_start_time_state,
+ significant_changes_only,
+ minimal_response,
+ no_attributes,
+ compressed_state_format,
)
- _target = _legacy_get_significant_states
- else:
- _target = _modern_get_significant_states
- return _target(
- hass,
- start_time,
- end_time,
- entity_ids,
- filters,
- include_start_time_state,
- significant_changes_only,
- minimal_response,
- no_attributes,
- compressed_state_format,
+
+def _significant_states_stmt(
+ start_time_ts: float,
+ end_time_ts: float | None,
+ single_metadata_id: int | None,
+ metadata_ids: list[int],
+ metadata_ids_in_significant_domains: list[int],
+ significant_changes_only: bool,
+ no_attributes: bool,
+ include_start_time_state: bool,
+ run_start_ts: float | None,
+ slow_dependent_subquery: bool,
+) -> Select | CompoundSelect:
+ """Query the database for significant state changes."""
+ include_last_changed = not significant_changes_only
+ stmt = _stmt_and_join_attributes(no_attributes, include_last_changed, False)
+ if significant_changes_only:
+ # Since we are filtering on entity_id (metadata_id) we can avoid
+ # the join of the states_meta table since we already know which
+ # metadata_ids are in the significant domains.
+ if metadata_ids_in_significant_domains:
+ stmt = stmt.filter(
+ States.metadata_id.in_(metadata_ids_in_significant_domains)
+ | (States.last_changed_ts == States.last_updated_ts)
+ | States.last_changed_ts.is_(None)
+ )
+ else:
+ stmt = stmt.filter(
+ (States.last_changed_ts == States.last_updated_ts)
+ | States.last_changed_ts.is_(None)
+ )
+ stmt = stmt.filter(States.metadata_id.in_(metadata_ids)).filter(
+ States.last_updated_ts > start_time_ts
)
+ if end_time_ts:
+ stmt = stmt.filter(States.last_updated_ts < end_time_ts)
+ if not no_attributes:
+ stmt = stmt.outerjoin(
+ StateAttributes, States.attributes_id == StateAttributes.attributes_id
+ )
+ if not include_start_time_state or not run_start_ts:
+ return stmt.order_by(States.metadata_id, States.last_updated_ts)
+ unioned_subquery = union_all(
+ _select_from_subquery(
+ _get_start_time_state_stmt(
+ start_time_ts,
+ single_metadata_id,
+ metadata_ids,
+ no_attributes,
+ include_last_changed,
+ slow_dependent_subquery,
+ ).subquery(),
+ no_attributes,
+ include_last_changed,
+ False,
+ ),
+ _select_from_subquery(
+ stmt.subquery(), no_attributes, include_last_changed, False
+ ),
+ ).subquery()
+ return _select_from_subquery(
+ unioned_subquery,
+ no_attributes,
+ include_last_changed,
+ False,
+ ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts)
def get_significant_states_with_session(
@@ -128,27 +232,224 @@ def get_significant_states_with_session(
no_attributes: bool = False,
compressed_state_format: bool = False,
) -> dict[str, list[State | dict[str, Any]]]:
- """Return a dict of significant states during a time period."""
- if not get_instance(hass).states_meta_manager.active:
- from .legacy import ( # noqa: PLC0415
- get_significant_states_with_session as _legacy_get_significant_states_with_session,
- )
+ """Return states changes during UTC period start_time - end_time.
- _target = _legacy_get_significant_states_with_session
+ entity_ids is an optional iterable of entities to include in the results.
+
+ filters is an optional SQLAlchemy filter which will be applied to the database
+ queries unless entity_ids is given, in which case its ignored.
+
+ Significant states are all states where there is a state change,
+ as well as all states from certain domains (for instance
+ thermostat so that we get current temperature in our graphs).
+ """
+ if filters is not None:
+ raise NotImplementedError("Filters are no longer supported")
+ if not entity_ids:
+ raise ValueError("entity_ids must be provided")
+ entity_id_to_metadata_id: dict[str, int | None] | None = None
+ metadata_ids_in_significant_domains: list[int] = []
+ instance = get_instance(hass)
+ if not (
+ entity_id_to_metadata_id := instance.states_meta_manager.get_many(
+ entity_ids, session, False
+ )
+ ) or not (possible_metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)):
+ return {}
+ metadata_ids = possible_metadata_ids
+ if significant_changes_only:
+ metadata_ids_in_significant_domains = [
+ metadata_id
+ for entity_id, metadata_id in entity_id_to_metadata_id.items()
+ if metadata_id is not None
+ and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS
+ ]
+ oldest_ts: float | None = None
+ if include_start_time_state and not (
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
+ ):
+ include_start_time_state = False
+ start_time_ts = start_time.timestamp()
+ end_time_ts = datetime_to_timestamp_or_none(end_time)
+ single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
+ rows: list[Row] = []
+ if TYPE_CHECKING:
+ assert instance.database_engine is not None
+ slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery
+ if include_start_time_state and slow_dependent_subquery:
+ # https://github.com/home-assistant/core/issues/137178
+ # If we include the start time state we need to limit the
+ # number of metadata_ids we query for at a time to avoid
+ # hitting limits in the MySQL optimizer that prevent
+ # the start time state query from using an index-only optimization
+ # to find the start time state.
+ iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY)
else:
- _target = _modern_get_significant_states_with_session
- return _target(
- hass,
- session,
- start_time,
- end_time,
+ iter_metadata_ids = (metadata_ids,)
+ for metadata_ids_chunk in iter_metadata_ids:
+ stmt = _generate_significant_states_with_session_stmt(
+ start_time_ts,
+ end_time_ts,
+ single_metadata_id,
+ metadata_ids_chunk,
+ metadata_ids_in_significant_domains,
+ significant_changes_only,
+ no_attributes,
+ include_start_time_state,
+ oldest_ts,
+ slow_dependent_subquery,
+ )
+ row_chunk = cast(
+ list[Row],
+ execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
+ )
+ if rows:
+ rows += row_chunk
+ else:
+ # If we have no rows yet, we can just assign the chunk
+ # as this is the common case since its rare that
+ # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
+ rows = row_chunk
+ return _sorted_states_to_dict(
+ rows,
+ start_time_ts if include_start_time_state else None,
entity_ids,
- filters,
- include_start_time_state,
- significant_changes_only,
+ entity_id_to_metadata_id,
minimal_response,
- no_attributes,
compressed_state_format,
+ no_attributes=no_attributes,
+ )
+
+
+def _generate_significant_states_with_session_stmt(
+ start_time_ts: float,
+ end_time_ts: float | None,
+ single_metadata_id: int | None,
+ metadata_ids: list[int],
+ metadata_ids_in_significant_domains: list[int],
+ significant_changes_only: bool,
+ no_attributes: bool,
+ include_start_time_state: bool,
+ oldest_ts: float | None,
+ slow_dependent_subquery: bool,
+) -> StatementLambdaElement:
+ return lambda_stmt(
+ lambda: _significant_states_stmt(
+ start_time_ts,
+ end_time_ts,
+ single_metadata_id,
+ metadata_ids,
+ metadata_ids_in_significant_domains,
+ significant_changes_only,
+ no_attributes,
+ include_start_time_state,
+ oldest_ts,
+ slow_dependent_subquery,
+ ),
+ track_on=[
+ bool(single_metadata_id),
+ bool(metadata_ids_in_significant_domains),
+ bool(end_time_ts),
+ significant_changes_only,
+ no_attributes,
+ include_start_time_state,
+ slow_dependent_subquery,
+ ],
+ )
+
+
+def get_full_significant_states_with_session(
+ hass: HomeAssistant,
+ session: Session,
+ start_time: datetime,
+ end_time: datetime | None = None,
+ entity_ids: list[str] | None = None,
+ filters: Filters | None = None,
+ include_start_time_state: bool = True,
+ significant_changes_only: bool = True,
+ no_attributes: bool = False,
+) -> dict[str, list[State]]:
+ """Variant of get_significant_states_with_session.
+
+ Difference with get_significant_states_with_session is that it does not
+ return minimal responses.
+ """
+ return cast(
+ dict[str, list[State]],
+ get_significant_states_with_session(
+ hass=hass,
+ session=session,
+ start_time=start_time,
+ end_time=end_time,
+ entity_ids=entity_ids,
+ filters=filters,
+ include_start_time_state=include_start_time_state,
+ significant_changes_only=significant_changes_only,
+ minimal_response=False,
+ no_attributes=no_attributes,
+ ),
+ )
+
+
+def _state_changed_during_period_stmt(
+ start_time_ts: float,
+ end_time_ts: float | None,
+ single_metadata_id: int,
+ no_attributes: bool,
+ limit: int | None,
+ include_start_time_state: bool,
+ run_start_ts: float | None,
+) -> Select | CompoundSelect:
+ stmt = (
+ _stmt_and_join_attributes(no_attributes, False, True)
+ .filter(
+ (
+ (States.last_changed_ts == States.last_updated_ts)
+ | States.last_changed_ts.is_(None)
+ )
+ & (States.last_updated_ts > start_time_ts)
+ )
+ .filter(States.metadata_id == single_metadata_id)
+ )
+ if end_time_ts:
+ stmt = stmt.filter(States.last_updated_ts < end_time_ts)
+ if not no_attributes:
+ stmt = stmt.outerjoin(
+ StateAttributes, States.attributes_id == StateAttributes.attributes_id
+ )
+ if limit:
+ stmt = stmt.limit(limit)
+ stmt = stmt.order_by(States.metadata_id, States.last_updated_ts)
+ if not include_start_time_state or not run_start_ts:
+ # If we do not need the start time state or the
+ # oldest possible timestamp is newer than the start time
+ # we can return the statement as is as there will
+ # never be a start time state.
+ return stmt
+ return _select_from_subquery(
+ union_all(
+ _select_from_subquery(
+ _get_single_entity_start_time_stmt(
+ start_time_ts,
+ single_metadata_id,
+ no_attributes,
+ False,
+ True,
+ ).subquery(),
+ no_attributes,
+ False,
+ True,
+ ),
+ _select_from_subquery(
+ stmt.subquery(),
+ no_attributes,
+ False,
+ True,
+ ),
+ ).subquery(),
+ no_attributes,
+ False,
+ True,
)
@@ -162,22 +463,474 @@ def state_changes_during_period(
limit: int | None = None,
include_start_time_state: bool = True,
) -> dict[str, list[State]]:
- """Return a list of states that changed during a time period."""
- if not get_instance(hass).states_meta_manager.active:
- from .legacy import ( # noqa: PLC0415
- state_changes_during_period as _legacy_state_changes_during_period,
+ """Return states changes during UTC period start_time - end_time."""
+ if not entity_id:
+ raise ValueError("entity_id must be provided")
+ entity_ids = [entity_id.lower()]
+
+ with session_scope(hass=hass, read_only=True) as session:
+ instance = get_instance(hass)
+ if not (
+ possible_metadata_id := instance.states_meta_manager.get(
+ entity_id, session, False
+ )
+ ):
+ return {}
+ single_metadata_id = possible_metadata_id
+ entity_id_to_metadata_id: dict[str, int | None] = {
+ entity_id: single_metadata_id
+ }
+ oldest_ts: float | None = None
+ if include_start_time_state and not (
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
+ ):
+ include_start_time_state = False
+ start_time_ts = start_time.timestamp()
+ end_time_ts = datetime_to_timestamp_or_none(end_time)
+ stmt = lambda_stmt(
+ lambda: _state_changed_during_period_stmt(
+ start_time_ts,
+ end_time_ts,
+ single_metadata_id,
+ no_attributes,
+ limit,
+ include_start_time_state,
+ oldest_ts,
+ ),
+ track_on=[
+ bool(end_time_ts),
+ no_attributes,
+ bool(limit),
+ include_start_time_state,
+ ],
+ )
+ return cast(
+ dict[str, list[State]],
+ _sorted_states_to_dict(
+ execute_stmt_lambda_element(
+ session, stmt, None, end_time, orm_rows=False
+ ),
+ start_time_ts if include_start_time_state else None,
+ entity_ids,
+ entity_id_to_metadata_id,
+ descending=descending,
+ no_attributes=no_attributes,
+ ),
)
- _target = _legacy_state_changes_during_period
- else:
- _target = _modern_state_changes_during_period
- return _target(
- hass,
- start_time,
- end_time,
- entity_id,
- no_attributes,
- descending,
- limit,
- include_start_time_state,
+
+def _get_last_state_changes_single_stmt(metadata_id: int) -> Select:
+ return (
+ _stmt_and_join_attributes(False, False, False)
+ .join(
+ (
+ lastest_state_for_metadata_id := (
+ select(
+ States.metadata_id.label("max_metadata_id"),
+ func.max(States.last_updated_ts).label("max_last_updated"),
+ )
+ .filter(States.metadata_id == metadata_id)
+ .group_by(States.metadata_id)
+ .subquery()
+ )
+ ),
+ and_(
+ States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id,
+ States.last_updated_ts
+ == lastest_state_for_metadata_id.c.max_last_updated,
+ ),
+ )
+ .outerjoin(
+ StateAttributes, States.attributes_id == StateAttributes.attributes_id
+ )
+ .order_by(States.state_id.desc())
)
+
+
+def _get_last_state_changes_multiple_stmt(
+ number_of_states: int, metadata_id: int
+) -> Select:
+ return (
+ _stmt_and_join_attributes(False, False, True)
+ .where(
+ States.state_id
+ == (
+ select(States.state_id)
+ .filter(States.metadata_id == metadata_id)
+ .order_by(States.last_updated_ts.desc())
+ .limit(number_of_states)
+ .subquery()
+ ).c.state_id
+ )
+ .outerjoin(
+ StateAttributes, States.attributes_id == StateAttributes.attributes_id
+ )
+ .order_by(States.state_id.desc())
+ )
+
+
+def get_last_state_changes(
+ hass: HomeAssistant, number_of_states: int, entity_id: str
+) -> dict[str, list[State]]:
+ """Return the last number_of_states."""
+ entity_id_lower = entity_id.lower()
+ entity_ids = [entity_id_lower]
+
+ # Calling this function with number_of_states > 1 can cause instability
+ # because it has to scan the table to find the last number_of_states states
+ # because the metadata_id_last_updated_ts index is in ascending order.
+
+ with session_scope(hass=hass, read_only=True) as session:
+ instance = get_instance(hass)
+ if not (
+ possible_metadata_id := instance.states_meta_manager.get(
+ entity_id, session, False
+ )
+ ):
+ return {}
+ metadata_id = possible_metadata_id
+ entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id}
+ if number_of_states == 1:
+ stmt = lambda_stmt(
+ lambda: _get_last_state_changes_single_stmt(metadata_id),
+ )
+ else:
+ stmt = lambda_stmt(
+ lambda: _get_last_state_changes_multiple_stmt(
+ number_of_states, metadata_id
+ ),
+ )
+ states = list(execute_stmt_lambda_element(session, stmt, orm_rows=False))
+ return cast(
+ dict[str, list[State]],
+ _sorted_states_to_dict(
+ reversed(states),
+ None,
+ entity_ids,
+ entity_id_to_metadata_id,
+ no_attributes=False,
+ ),
+ )
+
+
+def _get_start_time_state_for_entities_stmt_dependent_sub_query(
+ epoch_time: float,
+ metadata_ids: list[int],
+ no_attributes: bool,
+ include_last_changed: bool,
+) -> Select:
+ """Baked query to get states for specific entities."""
+ # Engine has a fast dependent subquery optimizer
+ # This query is the result of significant research in
+ # https://github.com/home-assistant/core/issues/132865
+ # A reverse index scan with a limit 1 is the fastest way to get the
+ # last state change before a specific point in time for all supported
+ # databases. Since all databases support this query as a join
+ # condition we can use it as a subquery to get the last state change
+ # before a specific point in time for all entities.
+ stmt = (
+ _stmt_and_join_attributes_for_start_state(
+ no_attributes=no_attributes,
+ include_last_changed=include_last_changed,
+ include_last_reported=False,
+ )
+ .select_from(StatesMeta)
+ .join(
+ States,
+ and_(
+ States.last_updated_ts
+ == (
+ select(States.last_updated_ts)
+ .where(
+ (StatesMeta.metadata_id == States.metadata_id)
+ & (States.last_updated_ts < epoch_time)
+ )
+ .order_by(States.last_updated_ts.desc())
+ .limit(1)
+ )
+ .scalar_subquery()
+ .correlate(StatesMeta),
+ States.metadata_id == StatesMeta.metadata_id,
+ ),
+ )
+ .where(StatesMeta.metadata_id.in_(metadata_ids))
+ )
+ if no_attributes:
+ return stmt
+ return stmt.outerjoin(
+ StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
+ )
+
+
+def _get_start_time_state_for_entities_stmt_group_by(
+ epoch_time: float,
+ metadata_ids: list[int],
+ no_attributes: bool,
+ include_last_changed: bool,
+) -> Select:
+ """Baked query to get states for specific entities."""
+ # Simple group-by for MySQL, must use less
+ # than 1000 metadata_ids in the IN clause for MySQL
+ # or it will optimize poorly. Callers are responsible
+ # for ensuring that the number of metadata_ids is less
+ # than 1000.
+ most_recent_states_for_entities_by_date = (
+ select(
+ States.metadata_id.label("max_metadata_id"),
+ func.max(States.last_updated_ts).label("max_last_updated"),
+ )
+ .filter(
+ (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
+ )
+ .group_by(States.metadata_id)
+ .subquery()
+ )
+ stmt = (
+ _stmt_and_join_attributes_for_start_state(
+ no_attributes=no_attributes,
+ include_last_changed=include_last_changed,
+ include_last_reported=False,
+ )
+ .join(
+ most_recent_states_for_entities_by_date,
+ and_(
+ States.metadata_id
+ == most_recent_states_for_entities_by_date.c.max_metadata_id,
+ States.last_updated_ts
+ == most_recent_states_for_entities_by_date.c.max_last_updated,
+ ),
+ )
+ .filter(
+ (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
+ )
+ )
+ if no_attributes:
+ return stmt
+ return stmt.outerjoin(
+ StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
+ )
+
+
+def _get_oldest_possible_ts(
+ hass: HomeAssistant, utc_point_in_time: datetime
+) -> float | None:
+ """Return the oldest possible timestamp.
+
+ Returns None if there are no states as old as utc_point_in_time.
+ """
+
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
+ if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp():
+ return oldest_ts
+ return None
+
+
+def _get_start_time_state_stmt(
+ epoch_time: float,
+ single_metadata_id: int | None,
+ metadata_ids: list[int],
+ no_attributes: bool,
+ include_last_changed: bool,
+ slow_dependent_subquery: bool,
+) -> Select:
+ """Return the states at a specific point in time."""
+ if single_metadata_id:
+ # Use an entirely different (and extremely fast) query if we only
+ # have a single entity id
+ return _get_single_entity_start_time_stmt(
+ epoch_time,
+ single_metadata_id,
+ no_attributes,
+ include_last_changed,
+ False,
+ )
+ # We have more than one entity to look at so we need to do a query on states
+ # since the last recorder run started.
+ if slow_dependent_subquery:
+ return _get_start_time_state_for_entities_stmt_group_by(
+ epoch_time,
+ metadata_ids,
+ no_attributes,
+ include_last_changed,
+ )
+
+ return _get_start_time_state_for_entities_stmt_dependent_sub_query(
+ epoch_time,
+ metadata_ids,
+ no_attributes,
+ include_last_changed,
+ )
+
+
+def _get_single_entity_start_time_stmt(
+ epoch_time: float,
+ metadata_id: int,
+ no_attributes: bool,
+ include_last_changed: bool,
+ include_last_reported: bool,
+) -> Select:
+ # Use an entirely different (and extremely fast) query if we only
+ # have a single entity id
+ stmt = (
+ _stmt_and_join_attributes_for_start_state(
+ no_attributes, include_last_changed, include_last_reported
+ )
+ .filter(
+ States.last_updated_ts < epoch_time,
+ States.metadata_id == metadata_id,
+ )
+ .order_by(States.last_updated_ts.desc())
+ .limit(1)
+ )
+ if no_attributes:
+ return stmt
+ return stmt.outerjoin(
+ StateAttributes, States.attributes_id == StateAttributes.attributes_id
+ )
+
+
+def _sorted_states_to_dict(
+ states: Iterable[Row],
+ start_time_ts: float | None,
+ entity_ids: list[str],
+ entity_id_to_metadata_id: dict[str, int | None],
+ minimal_response: bool = False,
+ compressed_state_format: bool = False,
+ descending: bool = False,
+ no_attributes: bool = False,
+) -> dict[str, list[State | dict[str, Any]]]:
+ """Convert SQL results into JSON friendly data structure.
+
+ This takes our state list and turns it into a JSON friendly data
+ structure {'entity_id': [list of states], 'entity_id2': [list of states]}
+
+ States must be sorted by entity_id and last_updated
+
+ We also need to go back and create a synthetic zero data point for
+ each list of states, otherwise our graphs won't start on the Y
+ axis correctly.
+ """
+ field_map = _FIELD_MAP
+ state_class: Callable[
+ [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool],
+ State | dict[str, Any],
+ ]
+ if compressed_state_format:
+ state_class = row_to_compressed_state
+ attr_time = COMPRESSED_STATE_LAST_UPDATED
+ attr_state = COMPRESSED_STATE_STATE
+ else:
+ state_class = LazyState
+ attr_time = LAST_CHANGED_KEY
+ attr_state = STATE_KEY
+
+ # Set all entity IDs to empty lists in result set to maintain the order
+ result: dict[str, list[State | dict[str, Any]]] = {
+ entity_id: [] for entity_id in entity_ids
+ }
+ metadata_id_to_entity_id: dict[int, str] = {}
+ metadata_id_to_entity_id = {
+ v: k for k, v in entity_id_to_metadata_id.items() if v is not None
+ }
+ # Get the states at the start time
+ if len(entity_ids) == 1:
+ metadata_id = entity_id_to_metadata_id[entity_ids[0]]
+ assert metadata_id is not None # should not be possible if we got here
+ states_iter: Iterable[tuple[int, Iterator[Row]]] = (
+ (metadata_id, iter(states)),
+ )
+ else:
+ key_func = itemgetter(field_map["metadata_id"])
+ states_iter = groupby(states, key_func)
+
+ state_idx = field_map["state"]
+ last_updated_ts_idx = field_map["last_updated_ts"]
+
+ # Append all changes to it
+ for metadata_id, group in states_iter:
+ entity_id = metadata_id_to_entity_id[metadata_id]
+ attr_cache: dict[str, dict[str, Any]] = {}
+ ent_results = result[entity_id]
+ if (
+ not minimal_response
+ or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS
+ ):
+ ent_results.extend(
+ [
+ state_class(
+ db_state,
+ attr_cache,
+ start_time_ts,
+ entity_id,
+ db_state[state_idx],
+ db_state[last_updated_ts_idx],
+ False,
+ )
+ for db_state in group
+ ]
+ )
+ continue
+
+ prev_state: str | None = None
+ # With minimal response we only provide a native
+ # State for the first and last response. All the states
+ # in-between only provide the "state" and the
+ # "last_changed".
+ if not ent_results:
+ if (first_state := next(group, None)) is None:
+ continue
+ prev_state = first_state[state_idx]
+ ent_results.append(
+ state_class(
+ first_state,
+ attr_cache,
+ start_time_ts,
+ entity_id,
+ prev_state,
+ first_state[last_updated_ts_idx],
+ no_attributes,
+ )
+ )
+
+ #
+ # minimal_response only makes sense with last_updated == last_updated
+ #
+ # We use last_updated for for last_changed since its the same
+ #
+ # With minimal response we do not care about attribute
+ # changes so we can filter out duplicate states
+ if compressed_state_format:
+ # Compressed state format uses the timestamp directly
+ ent_results.extend(
+ [
+ {
+ attr_state: (prev_state := state),
+ attr_time: row[last_updated_ts_idx],
+ }
+ for row in group
+ if (state := row[state_idx]) != prev_state
+ ]
+ )
+ continue
+
+ # Non-compressed state format returns an ISO formatted string
+ _utc_from_timestamp = dt_util.utc_from_timestamp
+ ent_results.extend(
+ [
+ {
+ attr_state: (prev_state := state),
+ attr_time: _utc_from_timestamp(
+ row[last_updated_ts_idx]
+ ).isoformat(),
+ }
+ for row in group
+ if (state := row[state_idx]) != prev_state
+ ]
+ )
+
+ if descending:
+ for ent_results in result.values():
+ ent_results.reverse()
+
+ # Filter out the empty lists if some states had 0 results.
+ return {key: val for key, val in result.items() if val}
diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py
deleted file mode 100644
index 4323ad9466b..00000000000
--- a/homeassistant/components/recorder/history/legacy.py
+++ /dev/null
@@ -1,664 +0,0 @@
-"""Provide pre-made queries on top of the recorder component."""
-
-from __future__ import annotations
-
-from collections import defaultdict
-from collections.abc import Callable, Iterable, Iterator
-from datetime import datetime
-from itertools import groupby
-from operator import attrgetter
-import time
-from typing import Any, cast
-
-from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select
-from sqlalchemy.engine.row import Row
-from sqlalchemy.orm.properties import MappedColumn
-from sqlalchemy.orm.session import Session
-from sqlalchemy.sql.expression import literal
-from sqlalchemy.sql.lambdas import StatementLambdaElement
-
-from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
-from homeassistant.core import HomeAssistant, State, split_entity_id
-from homeassistant.helpers.recorder import get_instance
-from homeassistant.util import dt as dt_util
-
-from ..db_schema import StateAttributes, States
-from ..filters import Filters
-from ..models import process_timestamp_to_utc_isoformat
-from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state
-from ..util import execute_stmt_lambda_element, session_scope
-from .const import (
- LAST_CHANGED_KEY,
- NEED_ATTRIBUTE_DOMAINS,
- SIGNIFICANT_DOMAINS,
- SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE,
- STATE_KEY,
-)
-
-_BASE_STATES = (
- States.entity_id,
- States.state,
- States.last_changed_ts,
- States.last_updated_ts,
-)
-_BASE_STATES_NO_LAST_CHANGED = (
- States.entity_id,
- States.state,
- literal(value=None).label("last_changed_ts"),
- States.last_updated_ts,
-)
-_QUERY_STATE_NO_ATTR = (
- *_BASE_STATES,
- literal(value=None, type_=Text).label("attributes"),
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (
- *_BASE_STATES_NO_LAST_CHANGED,
- literal(value=None, type_=Text).label("attributes"),
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-_BASE_STATES_PRE_SCHEMA_31 = (
- States.entity_id,
- States.state,
- States.last_changed,
- States.last_updated,
-)
-_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
- States.entity_id,
- States.state,
- literal(value=None, type_=Text).label("last_changed"),
- States.last_updated,
-)
-_QUERY_STATE_NO_ATTR_PRE_SCHEMA_31 = (
- *_BASE_STATES_PRE_SCHEMA_31,
- literal(value=None, type_=Text).label("attributes"),
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
- *_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
- literal(value=None, type_=Text).label("attributes"),
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-# Remove QUERY_STATES_PRE_SCHEMA_25
-# and the migration_in_progress check
-# once schema 26 is created
-_QUERY_STATES_PRE_SCHEMA_25 = (
- *_BASE_STATES_PRE_SCHEMA_31,
- States.attributes,
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-_QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED = (
- *_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
- States.attributes,
- literal(value=None, type_=Text).label("shared_attrs"),
-)
-_QUERY_STATES_PRE_SCHEMA_31 = (
- *_BASE_STATES_PRE_SCHEMA_31,
- # Remove States.attributes once all attributes are in StateAttributes.shared_attrs
- States.attributes,
- StateAttributes.shared_attrs,
-)
-_QUERY_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31 = (
- *_BASE_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31,
- # Remove States.attributes once all attributes are in StateAttributes.shared_attrs
- States.attributes,
- StateAttributes.shared_attrs,
-)
-_QUERY_STATES = (
- *_BASE_STATES,
- # Remove States.attributes once all attributes are in StateAttributes.shared_attrs
- States.attributes,
- StateAttributes.shared_attrs,
-)
-_QUERY_STATES_NO_LAST_CHANGED = (
- *_BASE_STATES_NO_LAST_CHANGED,
- # Remove States.attributes once all attributes are in StateAttributes.shared_attrs
- States.attributes,
- StateAttributes.shared_attrs,
-)
-_FIELD_MAP = {
- cast(MappedColumn, field).name: idx
- for idx, field in enumerate(_QUERY_STATE_NO_ATTR)
-}
-_FIELD_MAP_PRE_SCHEMA_31 = {
- cast(MappedColumn, field).name: idx
- for idx, field in enumerate(_QUERY_STATES_PRE_SCHEMA_31)
-}
-
-
-def _lambda_stmt_and_join_attributes(
- no_attributes: bool, include_last_changed: bool = True
-) -> tuple[StatementLambdaElement, bool]:
- """Return the lambda_stmt and if StateAttributes should be joined.
-
- Because these are lambda_stmt the values inside the lambdas need
- to be explicitly written out to avoid caching the wrong values.
- """
- # If no_attributes was requested we do the query
- # without the attributes fields and do not join the
- # state_attributes table
- if no_attributes:
- if include_last_changed:
- return (
- lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)),
- False,
- )
- return (
- lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)),
- False,
- )
-
- if include_last_changed:
- return lambda_stmt(lambda: select(*_QUERY_STATES)), True
- return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True
-
-
-def get_significant_states(
- hass: HomeAssistant,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- minimal_response: bool = False,
- no_attributes: bool = False,
- compressed_state_format: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Wrap get_significant_states_with_session with an sql session."""
- with session_scope(hass=hass, read_only=True) as session:
- return get_significant_states_with_session(
- hass,
- session,
- start_time,
- end_time,
- entity_ids,
- filters,
- include_start_time_state,
- significant_changes_only,
- minimal_response,
- no_attributes,
- compressed_state_format,
- )
-
-
-def _significant_states_stmt(
- start_time: datetime,
- end_time: datetime | None,
- entity_ids: list[str],
- significant_changes_only: bool,
- no_attributes: bool,
-) -> StatementLambdaElement:
- """Query the database for significant state changes."""
- stmt, join_attributes = _lambda_stmt_and_join_attributes(
- no_attributes, include_last_changed=not significant_changes_only
- )
- if (
- len(entity_ids) == 1
- and significant_changes_only
- and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS
- ):
- stmt += lambda q: q.filter(
- (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- )
- elif significant_changes_only:
- stmt += lambda q: q.filter(
- or_(
- *[
- States.entity_id.like(entity_domain)
- for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE
- ],
- (
- (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- ),
- )
- )
- stmt += lambda q: q.filter(States.entity_id.in_(entity_ids))
-
- start_time_ts = start_time.timestamp()
- stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts)
- if end_time:
- end_time_ts = end_time.timestamp()
- stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts)
-
- if join_attributes:
- stmt += lambda q: q.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts)
- return stmt
-
-
-def get_significant_states_with_session(
- hass: HomeAssistant,
- session: Session,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- minimal_response: bool = False,
- no_attributes: bool = False,
- compressed_state_format: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Return states changes during UTC period start_time - end_time.
-
- entity_ids is an optional iterable of entities to include in the results.
-
- filters is an optional SQLAlchemy filter which will be applied to the database
- queries unless entity_ids is given, in which case its ignored.
-
- Significant states are all states where there is a state change,
- as well as all states from certain domains (for instance
- thermostat so that we get current temperature in our graphs).
- """
- if filters is not None:
- raise NotImplementedError("Filters are no longer supported")
- if not entity_ids:
- raise ValueError("entity_ids must be provided")
- stmt = _significant_states_stmt(
- start_time,
- end_time,
- entity_ids,
- significant_changes_only,
- no_attributes,
- )
- states = execute_stmt_lambda_element(session, stmt, None, end_time)
- return _sorted_states_to_dict(
- hass,
- session,
- states,
- start_time,
- entity_ids,
- include_start_time_state,
- minimal_response,
- no_attributes,
- compressed_state_format,
- )
-
-
-def get_full_significant_states_with_session(
- hass: HomeAssistant,
- session: Session,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- no_attributes: bool = False,
-) -> dict[str, list[State]]:
- """Variant of get_significant_states_with_session.
-
- Difference with get_significant_states_with_session is that it does not
- return minimal responses.
- """
- return cast(
- dict[str, list[State]],
- get_significant_states_with_session(
- hass=hass,
- session=session,
- start_time=start_time,
- end_time=end_time,
- entity_ids=entity_ids,
- filters=filters,
- include_start_time_state=include_start_time_state,
- significant_changes_only=significant_changes_only,
- minimal_response=False,
- no_attributes=no_attributes,
- ),
- )
-
-
-def _state_changed_during_period_stmt(
- start_time: datetime,
- end_time: datetime | None,
- entity_id: str,
- no_attributes: bool,
- descending: bool,
- limit: int | None,
-) -> StatementLambdaElement:
- stmt, join_attributes = _lambda_stmt_and_join_attributes(
- no_attributes, include_last_changed=False
- )
- start_time_ts = start_time.timestamp()
- stmt += lambda q: q.filter(
- (
- (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- )
- & (States.last_updated_ts > start_time_ts)
- )
- if end_time:
- end_time_ts = end_time.timestamp()
- stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts)
- stmt += lambda q: q.filter(States.entity_id == entity_id)
- if join_attributes:
- stmt += lambda q: q.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- if descending:
- stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts.desc())
- else:
- stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts)
-
- if limit:
- stmt += lambda q: q.limit(limit)
- return stmt
-
-
-def state_changes_during_period(
- hass: HomeAssistant,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_id: str | None = None,
- no_attributes: bool = False,
- descending: bool = False,
- limit: int | None = None,
- include_start_time_state: bool = True,
-) -> dict[str, list[State]]:
- """Return states changes during UTC period start_time - end_time."""
- if not entity_id:
- raise ValueError("entity_id must be provided")
- entity_ids = [entity_id.lower()]
- with session_scope(hass=hass, read_only=True) as session:
- stmt = _state_changed_during_period_stmt(
- start_time,
- end_time,
- entity_id,
- no_attributes,
- descending,
- limit,
- )
- states = execute_stmt_lambda_element(session, stmt, None, end_time)
- return cast(
- dict[str, list[State]],
- _sorted_states_to_dict(
- hass,
- session,
- states,
- start_time,
- entity_ids,
- include_start_time_state=include_start_time_state,
- ),
- )
-
-
-def _get_last_state_changes_stmt(
- number_of_states: int, entity_id: str
-) -> StatementLambdaElement:
- stmt, join_attributes = _lambda_stmt_and_join_attributes(
- False, include_last_changed=False
- )
- stmt += lambda q: q.where(
- States.state_id
- == (
- select(States.state_id)
- .filter(States.entity_id == entity_id)
- .order_by(States.last_updated_ts.desc())
- .limit(number_of_states)
- .subquery()
- ).c.state_id
- )
- if join_attributes:
- stmt += lambda q: q.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
-
- stmt += lambda q: q.order_by(States.state_id.desc())
- return stmt
-
-
-def get_last_state_changes(
- hass: HomeAssistant, number_of_states: int, entity_id: str
-) -> dict[str, list[State]]:
- """Return the last number_of_states."""
- entity_id_lower = entity_id.lower()
- entity_ids = [entity_id_lower]
-
- with session_scope(hass=hass, read_only=True) as session:
- stmt = _get_last_state_changes_stmt(number_of_states, entity_id_lower)
- states = list(execute_stmt_lambda_element(session, stmt))
- return cast(
- dict[str, list[State]],
- _sorted_states_to_dict(
- hass,
- session,
- reversed(states),
- dt_util.utcnow(),
- entity_ids,
- include_start_time_state=False,
- ),
- )
-
-
-def _get_states_for_entities_stmt(
- run_start_ts: float,
- utc_point_in_time: datetime,
- entity_ids: list[str],
- no_attributes: bool,
-) -> StatementLambdaElement:
- """Baked query to get states for specific entities."""
- stmt, join_attributes = _lambda_stmt_and_join_attributes(
- no_attributes, include_last_changed=True
- )
- # We got an include-list of entities, accelerate the query by filtering already
- # in the inner query.
- utc_point_in_time_ts = utc_point_in_time.timestamp()
- stmt += lambda q: q.join(
- (
- most_recent_states_for_entities_by_date := (
- select(
- States.entity_id.label("max_entity_id"),
- func.max(States.last_updated_ts).label("max_last_updated"),
- )
- .filter(
- (States.last_updated_ts >= run_start_ts)
- & (States.last_updated_ts < utc_point_in_time_ts)
- )
- .filter(States.entity_id.in_(entity_ids))
- .group_by(States.entity_id)
- .subquery()
- )
- ),
- and_(
- States.entity_id == most_recent_states_for_entities_by_date.c.max_entity_id,
- States.last_updated_ts
- == most_recent_states_for_entities_by_date.c.max_last_updated,
- ),
- )
- if join_attributes:
- stmt += lambda q: q.outerjoin(
- StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
- )
- return stmt
-
-
-def _get_rows_with_session(
- hass: HomeAssistant,
- session: Session,
- utc_point_in_time: datetime,
- entity_ids: list[str],
- *,
- no_attributes: bool = False,
-) -> Iterable[Row]:
- """Return the states at a specific point in time."""
- if len(entity_ids) == 1:
- return execute_stmt_lambda_element(
- session,
- _get_single_entity_states_stmt(
- utc_point_in_time, entity_ids[0], no_attributes
- ),
- )
-
- oldest_ts = get_instance(hass).states_manager.oldest_ts
-
- if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp():
- # We don't have any states for the requested time
- return []
-
- # We have more than one entity to look at so we need to do a query on states
- # since the last recorder run started.
- stmt = _get_states_for_entities_stmt(
- oldest_ts, utc_point_in_time, entity_ids, no_attributes
- )
- return execute_stmt_lambda_element(session, stmt)
-
-
-def _get_single_entity_states_stmt(
- utc_point_in_time: datetime,
- entity_id: str,
- no_attributes: bool = False,
-) -> StatementLambdaElement:
- # Use an entirely different (and extremely fast) query if we only
- # have a single entity id
- stmt, join_attributes = _lambda_stmt_and_join_attributes(
- no_attributes, include_last_changed=True
- )
- utc_point_in_time_ts = utc_point_in_time.timestamp()
- stmt += (
- lambda q: q.filter(
- States.last_updated_ts < utc_point_in_time_ts,
- States.entity_id == entity_id,
- )
- .order_by(States.last_updated_ts.desc())
- .limit(1)
- )
- if join_attributes:
- stmt += lambda q: q.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- return stmt
-
-
-def _sorted_states_to_dict(
- hass: HomeAssistant,
- session: Session,
- states: Iterable[Row],
- start_time: datetime,
- entity_ids: list[str],
- include_start_time_state: bool = True,
- minimal_response: bool = False,
- no_attributes: bool = False,
- compressed_state_format: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Convert SQL results into JSON friendly data structure.
-
- This takes our state list and turns it into a JSON friendly data
- structure {'entity_id': [list of states], 'entity_id2': [list of states]}
-
- States must be sorted by entity_id and last_updated
-
- We also need to go back and create a synthetic zero data point for
- each list of states, otherwise our graphs won't start on the Y
- axis correctly.
- """
- state_class: Callable[
- [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any]
- ]
- if compressed_state_format:
- state_class = legacy_row_to_compressed_state
- attr_time = COMPRESSED_STATE_LAST_UPDATED
- attr_state = COMPRESSED_STATE_STATE
- else:
- state_class = LegacyLazyState
- attr_time = LAST_CHANGED_KEY
- attr_state = STATE_KEY
-
- result: dict[str, list[State | dict[str, Any]]] = defaultdict(list)
- # Set all entity IDs to empty lists in result set to maintain the order
- for ent_id in entity_ids:
- result[ent_id] = []
-
- # Get the states at the start time
- time.perf_counter()
- initial_states: dict[str, Row] = {}
- if include_start_time_state:
- initial_states = {
- row.entity_id: row
- for row in _get_rows_with_session(
- hass,
- session,
- start_time,
- entity_ids,
- no_attributes=no_attributes,
- )
- }
-
- if len(entity_ids) == 1:
- states_iter: Iterable[tuple[str, Iterator[Row]]] = (
- (entity_ids[0], iter(states)),
- )
- else:
- key_func = attrgetter("entity_id")
- states_iter = groupby(states, key_func)
-
- # Append all changes to it
- for ent_id, group in states_iter:
- attr_cache: dict[str, dict[str, Any]] = {}
- prev_state: Column | str
- ent_results = result[ent_id]
- if row := initial_states.pop(ent_id, None):
- prev_state = row.state
- ent_results.append(state_class(row, attr_cache, start_time))
-
- if not minimal_response or split_entity_id(ent_id)[0] in NEED_ATTRIBUTE_DOMAINS:
- ent_results.extend(
- state_class(db_state, attr_cache, None) for db_state in group
- )
- continue
-
- # With minimal response we only provide a native
- # State for the first and last response. All the states
- # in-between only provide the "state" and the
- # "last_changed".
- if not ent_results:
- if (first_state := next(group, None)) is None:
- continue
- prev_state = first_state.state
- ent_results.append(state_class(first_state, attr_cache, None))
-
- state_idx = _FIELD_MAP["state"]
-
- #
- # minimal_response only makes sense with last_updated == last_updated
- #
- # We use last_updated for for last_changed since its the same
- #
- # With minimal response we do not care about attribute
- # changes so we can filter out duplicate states
- last_updated_ts_idx = _FIELD_MAP["last_updated_ts"]
- if compressed_state_format:
- for row in group:
- if (state := row[state_idx]) != prev_state:
- ent_results.append(
- {
- attr_state: state,
- attr_time: row[last_updated_ts_idx],
- }
- )
- prev_state = state
- continue
-
- for row in group:
- if (state := row[state_idx]) != prev_state:
- ent_results.append(
- {
- attr_state: state,
- attr_time: process_timestamp_to_utc_isoformat(
- dt_util.utc_from_timestamp(row[last_updated_ts_idx])
- ),
- }
- )
- prev_state = state
-
- # If there are no states beyond the initial state,
- # the state a was never popped from initial_states
- for ent_id, row in initial_states.items():
- result[ent_id].append(state_class(row, {}, start_time))
-
- # Filter out the empty lists if some states had 0 results.
- return {key: val for key, val in result.items() if val}
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
deleted file mode 100644
index 566e30713f0..00000000000
--- a/homeassistant/components/recorder/history/modern.py
+++ /dev/null
@@ -1,935 +0,0 @@
-"""Provide pre-made queries on top of the recorder component."""
-
-from __future__ import annotations
-
-from collections.abc import Callable, Iterable, Iterator
-from datetime import datetime
-from itertools import groupby
-from operator import itemgetter
-from typing import TYPE_CHECKING, Any, cast
-
-from sqlalchemy import (
- CompoundSelect,
- Select,
- StatementLambdaElement,
- Subquery,
- and_,
- func,
- lambda_stmt,
- literal,
- select,
- union_all,
-)
-from sqlalchemy.engine.row import Row
-from sqlalchemy.orm.session import Session
-
-from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE
-from homeassistant.core import HomeAssistant, State, split_entity_id
-from homeassistant.helpers.recorder import get_instance
-from homeassistant.util import dt as dt_util
-from homeassistant.util.collection import chunked_or_all
-
-from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY
-from ..db_schema import (
- SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
- StateAttributes,
- States,
- StatesMeta,
-)
-from ..filters import Filters
-from ..models import (
- LazyState,
- datetime_to_timestamp_or_none,
- extract_metadata_ids,
- row_to_compressed_state,
-)
-from ..util import execute_stmt_lambda_element, session_scope
-from .const import (
- LAST_CHANGED_KEY,
- NEED_ATTRIBUTE_DOMAINS,
- SIGNIFICANT_DOMAINS,
- STATE_KEY,
-)
-
-_FIELD_MAP = {
- "metadata_id": 0,
- "state": 1,
- "last_updated_ts": 2,
-}
-
-
-def _stmt_and_join_attributes(
- no_attributes: bool,
- include_last_changed: bool,
- include_last_reported: bool,
-) -> Select:
- """Return the statement and if StateAttributes should be joined."""
- _select = select(States.metadata_id, States.state, States.last_updated_ts)
- if include_last_changed:
- _select = _select.add_columns(States.last_changed_ts)
- if include_last_reported:
- _select = _select.add_columns(States.last_reported_ts)
- if not no_attributes:
- _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES)
- return _select
-
-
-def _stmt_and_join_attributes_for_start_state(
- no_attributes: bool,
- include_last_changed: bool,
- include_last_reported: bool,
-) -> Select:
- """Return the statement and if StateAttributes should be joined."""
- _select = select(States.metadata_id, States.state)
- _select = _select.add_columns(literal(value=0).label("last_updated_ts"))
- if include_last_changed:
- _select = _select.add_columns(literal(value=0).label("last_changed_ts"))
- if include_last_reported:
- _select = _select.add_columns(literal(value=0).label("last_reported_ts"))
- if not no_attributes:
- _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES)
- return _select
-
-
-def _select_from_subquery(
- subquery: Subquery | CompoundSelect,
- no_attributes: bool,
- include_last_changed: bool,
- include_last_reported: bool,
-) -> Select:
- """Return the statement to select from the union."""
- base_select = select(
- subquery.c.metadata_id,
- subquery.c.state,
- subquery.c.last_updated_ts,
- )
- if include_last_changed:
- base_select = base_select.add_columns(subquery.c.last_changed_ts)
- if include_last_reported:
- base_select = base_select.add_columns(subquery.c.last_reported_ts)
- if no_attributes:
- return base_select
- return base_select.add_columns(subquery.c.attributes)
-
-
-def get_significant_states(
- hass: HomeAssistant,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- minimal_response: bool = False,
- no_attributes: bool = False,
- compressed_state_format: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Wrap get_significant_states_with_session with an sql session."""
- with session_scope(hass=hass, read_only=True) as session:
- return get_significant_states_with_session(
- hass,
- session,
- start_time,
- end_time,
- entity_ids,
- filters,
- include_start_time_state,
- significant_changes_only,
- minimal_response,
- no_attributes,
- compressed_state_format,
- )
-
-
-def _significant_states_stmt(
- start_time_ts: float,
- end_time_ts: float | None,
- single_metadata_id: int | None,
- metadata_ids: list[int],
- metadata_ids_in_significant_domains: list[int],
- significant_changes_only: bool,
- no_attributes: bool,
- include_start_time_state: bool,
- run_start_ts: float | None,
- slow_dependent_subquery: bool,
-) -> Select | CompoundSelect:
- """Query the database for significant state changes."""
- include_last_changed = not significant_changes_only
- stmt = _stmt_and_join_attributes(no_attributes, include_last_changed, False)
- if significant_changes_only:
- # Since we are filtering on entity_id (metadata_id) we can avoid
- # the join of the states_meta table since we already know which
- # metadata_ids are in the significant domains.
- if metadata_ids_in_significant_domains:
- stmt = stmt.filter(
- States.metadata_id.in_(metadata_ids_in_significant_domains)
- | (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- )
- else:
- stmt = stmt.filter(
- (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- )
- stmt = stmt.filter(States.metadata_id.in_(metadata_ids)).filter(
- States.last_updated_ts > start_time_ts
- )
- if end_time_ts:
- stmt = stmt.filter(States.last_updated_ts < end_time_ts)
- if not no_attributes:
- stmt = stmt.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- if not include_start_time_state or not run_start_ts:
- return stmt.order_by(States.metadata_id, States.last_updated_ts)
- unioned_subquery = union_all(
- _select_from_subquery(
- _get_start_time_state_stmt(
- start_time_ts,
- single_metadata_id,
- metadata_ids,
- no_attributes,
- include_last_changed,
- slow_dependent_subquery,
- ).subquery(),
- no_attributes,
- include_last_changed,
- False,
- ),
- _select_from_subquery(
- stmt.subquery(), no_attributes, include_last_changed, False
- ),
- ).subquery()
- return _select_from_subquery(
- unioned_subquery,
- no_attributes,
- include_last_changed,
- False,
- ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts)
-
-
-def get_significant_states_with_session(
- hass: HomeAssistant,
- session: Session,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- minimal_response: bool = False,
- no_attributes: bool = False,
- compressed_state_format: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Return states changes during UTC period start_time - end_time.
-
- entity_ids is an optional iterable of entities to include in the results.
-
- filters is an optional SQLAlchemy filter which will be applied to the database
- queries unless entity_ids is given, in which case its ignored.
-
- Significant states are all states where there is a state change,
- as well as all states from certain domains (for instance
- thermostat so that we get current temperature in our graphs).
- """
- if filters is not None:
- raise NotImplementedError("Filters are no longer supported")
- if not entity_ids:
- raise ValueError("entity_ids must be provided")
- entity_id_to_metadata_id: dict[str, int | None] | None = None
- metadata_ids_in_significant_domains: list[int] = []
- instance = get_instance(hass)
- if not (
- entity_id_to_metadata_id := instance.states_meta_manager.get_many(
- entity_ids, session, False
- )
- ) or not (possible_metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)):
- return {}
- metadata_ids = possible_metadata_ids
- if significant_changes_only:
- metadata_ids_in_significant_domains = [
- metadata_id
- for entity_id, metadata_id in entity_id_to_metadata_id.items()
- if metadata_id is not None
- and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS
- ]
- oldest_ts: float | None = None
- if include_start_time_state and not (
- oldest_ts := _get_oldest_possible_ts(hass, start_time)
- ):
- include_start_time_state = False
- start_time_ts = start_time.timestamp()
- end_time_ts = datetime_to_timestamp_or_none(end_time)
- single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
- rows: list[Row] = []
- if TYPE_CHECKING:
- assert instance.database_engine is not None
- slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery
- if include_start_time_state and slow_dependent_subquery:
- # https://github.com/home-assistant/core/issues/137178
- # If we include the start time state we need to limit the
- # number of metadata_ids we query for at a time to avoid
- # hitting limits in the MySQL optimizer that prevent
- # the start time state query from using an index-only optimization
- # to find the start time state.
- iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY)
- else:
- iter_metadata_ids = (metadata_ids,)
- for metadata_ids_chunk in iter_metadata_ids:
- stmt = _generate_significant_states_with_session_stmt(
- start_time_ts,
- end_time_ts,
- single_metadata_id,
- metadata_ids_chunk,
- metadata_ids_in_significant_domains,
- significant_changes_only,
- no_attributes,
- include_start_time_state,
- oldest_ts,
- slow_dependent_subquery,
- )
- row_chunk = cast(
- list[Row],
- execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
- )
- if rows:
- rows += row_chunk
- else:
- # If we have no rows yet, we can just assign the chunk
- # as this is the common case since its rare that
- # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
- rows = row_chunk
- return _sorted_states_to_dict(
- rows,
- start_time_ts if include_start_time_state else None,
- entity_ids,
- entity_id_to_metadata_id,
- minimal_response,
- compressed_state_format,
- no_attributes=no_attributes,
- )
-
-
-def _generate_significant_states_with_session_stmt(
- start_time_ts: float,
- end_time_ts: float | None,
- single_metadata_id: int | None,
- metadata_ids: list[int],
- metadata_ids_in_significant_domains: list[int],
- significant_changes_only: bool,
- no_attributes: bool,
- include_start_time_state: bool,
- oldest_ts: float | None,
- slow_dependent_subquery: bool,
-) -> StatementLambdaElement:
- return lambda_stmt(
- lambda: _significant_states_stmt(
- start_time_ts,
- end_time_ts,
- single_metadata_id,
- metadata_ids,
- metadata_ids_in_significant_domains,
- significant_changes_only,
- no_attributes,
- include_start_time_state,
- oldest_ts,
- slow_dependent_subquery,
- ),
- track_on=[
- bool(single_metadata_id),
- bool(metadata_ids_in_significant_domains),
- bool(end_time_ts),
- significant_changes_only,
- no_attributes,
- include_start_time_state,
- slow_dependent_subquery,
- ],
- )
-
-
-def get_full_significant_states_with_session(
- hass: HomeAssistant,
- session: Session,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_ids: list[str] | None = None,
- filters: Filters | None = None,
- include_start_time_state: bool = True,
- significant_changes_only: bool = True,
- no_attributes: bool = False,
-) -> dict[str, list[State]]:
- """Variant of get_significant_states_with_session.
-
- Difference with get_significant_states_with_session is that it does not
- return minimal responses.
- """
- return cast(
- dict[str, list[State]],
- get_significant_states_with_session(
- hass=hass,
- session=session,
- start_time=start_time,
- end_time=end_time,
- entity_ids=entity_ids,
- filters=filters,
- include_start_time_state=include_start_time_state,
- significant_changes_only=significant_changes_only,
- minimal_response=False,
- no_attributes=no_attributes,
- ),
- )
-
-
-def _state_changed_during_period_stmt(
- start_time_ts: float,
- end_time_ts: float | None,
- single_metadata_id: int,
- no_attributes: bool,
- limit: int | None,
- include_start_time_state: bool,
- run_start_ts: float | None,
- include_last_reported: bool,
-) -> Select | CompoundSelect:
- stmt = (
- _stmt_and_join_attributes(no_attributes, False, include_last_reported)
- .filter(
- (
- (States.last_changed_ts == States.last_updated_ts)
- | States.last_changed_ts.is_(None)
- )
- & (States.last_updated_ts > start_time_ts)
- )
- .filter(States.metadata_id == single_metadata_id)
- )
- if end_time_ts:
- stmt = stmt.filter(States.last_updated_ts < end_time_ts)
- if not no_attributes:
- stmt = stmt.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- if limit:
- stmt = stmt.limit(limit)
- stmt = stmt.order_by(States.metadata_id, States.last_updated_ts)
- if not include_start_time_state or not run_start_ts:
- # If we do not need the start time state or the
- # oldest possible timestamp is newer than the start time
- # we can return the statement as is as there will
- # never be a start time state.
- return stmt
- return _select_from_subquery(
- union_all(
- _select_from_subquery(
- _get_single_entity_start_time_stmt(
- start_time_ts,
- single_metadata_id,
- no_attributes,
- False,
- include_last_reported,
- ).subquery(),
- no_attributes,
- False,
- include_last_reported,
- ),
- _select_from_subquery(
- stmt.subquery(),
- no_attributes,
- False,
- include_last_reported,
- ),
- ).subquery(),
- no_attributes,
- False,
- include_last_reported,
- )
-
-
-def state_changes_during_period(
- hass: HomeAssistant,
- start_time: datetime,
- end_time: datetime | None = None,
- entity_id: str | None = None,
- no_attributes: bool = False,
- descending: bool = False,
- limit: int | None = None,
- include_start_time_state: bool = True,
-) -> dict[str, list[State]]:
- """Return states changes during UTC period start_time - end_time."""
- has_last_reported = (
- get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION
- )
- if not entity_id:
- raise ValueError("entity_id must be provided")
- entity_ids = [entity_id.lower()]
-
- with session_scope(hass=hass, read_only=True) as session:
- instance = get_instance(hass)
- if not (
- possible_metadata_id := instance.states_meta_manager.get(
- entity_id, session, False
- )
- ):
- return {}
- single_metadata_id = possible_metadata_id
- entity_id_to_metadata_id: dict[str, int | None] = {
- entity_id: single_metadata_id
- }
- oldest_ts: float | None = None
- if include_start_time_state and not (
- oldest_ts := _get_oldest_possible_ts(hass, start_time)
- ):
- include_start_time_state = False
- start_time_ts = start_time.timestamp()
- end_time_ts = datetime_to_timestamp_or_none(end_time)
- stmt = lambda_stmt(
- lambda: _state_changed_during_period_stmt(
- start_time_ts,
- end_time_ts,
- single_metadata_id,
- no_attributes,
- limit,
- include_start_time_state,
- oldest_ts,
- has_last_reported,
- ),
- track_on=[
- bool(end_time_ts),
- no_attributes,
- bool(limit),
- include_start_time_state,
- has_last_reported,
- ],
- )
- return cast(
- dict[str, list[State]],
- _sorted_states_to_dict(
- execute_stmt_lambda_element(
- session, stmt, None, end_time, orm_rows=False
- ),
- start_time_ts if include_start_time_state else None,
- entity_ids,
- entity_id_to_metadata_id,
- descending=descending,
- no_attributes=no_attributes,
- ),
- )
-
-
-def _get_last_state_changes_single_stmt(metadata_id: int) -> Select:
- return (
- _stmt_and_join_attributes(False, False, False)
- .join(
- (
- lastest_state_for_metadata_id := (
- select(
- States.metadata_id.label("max_metadata_id"),
- func.max(States.last_updated_ts).label("max_last_updated"),
- )
- .filter(States.metadata_id == metadata_id)
- .group_by(States.metadata_id)
- .subquery()
- )
- ),
- and_(
- States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id,
- States.last_updated_ts
- == lastest_state_for_metadata_id.c.max_last_updated,
- ),
- )
- .outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- .order_by(States.state_id.desc())
- )
-
-
-def _get_last_state_changes_multiple_stmt(
- number_of_states: int, metadata_id: int, include_last_reported: bool
-) -> Select:
- return (
- _stmt_and_join_attributes(False, False, include_last_reported)
- .where(
- States.state_id
- == (
- select(States.state_id)
- .filter(States.metadata_id == metadata_id)
- .order_by(States.last_updated_ts.desc())
- .limit(number_of_states)
- .subquery()
- ).c.state_id
- )
- .outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
- .order_by(States.state_id.desc())
- )
-
-
-def get_last_state_changes(
- hass: HomeAssistant, number_of_states: int, entity_id: str
-) -> dict[str, list[State]]:
- """Return the last number_of_states."""
- has_last_reported = (
- get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION
- )
- entity_id_lower = entity_id.lower()
- entity_ids = [entity_id_lower]
-
- # Calling this function with number_of_states > 1 can cause instability
- # because it has to scan the table to find the last number_of_states states
- # because the metadata_id_last_updated_ts index is in ascending order.
-
- with session_scope(hass=hass, read_only=True) as session:
- instance = get_instance(hass)
- if not (
- possible_metadata_id := instance.states_meta_manager.get(
- entity_id, session, False
- )
- ):
- return {}
- metadata_id = possible_metadata_id
- entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id}
- if number_of_states == 1:
- stmt = lambda_stmt(
- lambda: _get_last_state_changes_single_stmt(metadata_id),
- )
- else:
- stmt = lambda_stmt(
- lambda: _get_last_state_changes_multiple_stmt(
- number_of_states, metadata_id, has_last_reported
- ),
- track_on=[has_last_reported],
- )
- states = list(execute_stmt_lambda_element(session, stmt, orm_rows=False))
- return cast(
- dict[str, list[State]],
- _sorted_states_to_dict(
- reversed(states),
- None,
- entity_ids,
- entity_id_to_metadata_id,
- no_attributes=False,
- ),
- )
-
-
-def _get_start_time_state_for_entities_stmt_dependent_sub_query(
- epoch_time: float,
- metadata_ids: list[int],
- no_attributes: bool,
- include_last_changed: bool,
-) -> Select:
- """Baked query to get states for specific entities."""
- # Engine has a fast dependent subquery optimizer
- # This query is the result of significant research in
- # https://github.com/home-assistant/core/issues/132865
- # A reverse index scan with a limit 1 is the fastest way to get the
- # last state change before a specific point in time for all supported
- # databases. Since all databases support this query as a join
- # condition we can use it as a subquery to get the last state change
- # before a specific point in time for all entities.
- stmt = (
- _stmt_and_join_attributes_for_start_state(
- no_attributes=no_attributes,
- include_last_changed=include_last_changed,
- include_last_reported=False,
- )
- .select_from(StatesMeta)
- .join(
- States,
- and_(
- States.last_updated_ts
- == (
- select(States.last_updated_ts)
- .where(
- (StatesMeta.metadata_id == States.metadata_id)
- & (States.last_updated_ts < epoch_time)
- )
- .order_by(States.last_updated_ts.desc())
- .limit(1)
- )
- .scalar_subquery()
- .correlate(StatesMeta),
- States.metadata_id == StatesMeta.metadata_id,
- ),
- )
- .where(StatesMeta.metadata_id.in_(metadata_ids))
- )
- if no_attributes:
- return stmt
- return stmt.outerjoin(
- StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
- )
-
-
-def _get_start_time_state_for_entities_stmt_group_by(
- epoch_time: float,
- metadata_ids: list[int],
- no_attributes: bool,
- include_last_changed: bool,
-) -> Select:
- """Baked query to get states for specific entities."""
- # Simple group-by for MySQL, must use less
- # than 1000 metadata_ids in the IN clause for MySQL
- # or it will optimize poorly. Callers are responsible
- # for ensuring that the number of metadata_ids is less
- # than 1000.
- most_recent_states_for_entities_by_date = (
- select(
- States.metadata_id.label("max_metadata_id"),
- func.max(States.last_updated_ts).label("max_last_updated"),
- )
- .filter(
- (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
- )
- .group_by(States.metadata_id)
- .subquery()
- )
- stmt = (
- _stmt_and_join_attributes_for_start_state(
- no_attributes=no_attributes,
- include_last_changed=include_last_changed,
- include_last_reported=False,
- )
- .join(
- most_recent_states_for_entities_by_date,
- and_(
- States.metadata_id
- == most_recent_states_for_entities_by_date.c.max_metadata_id,
- States.last_updated_ts
- == most_recent_states_for_entities_by_date.c.max_last_updated,
- ),
- )
- .filter(
- (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
- )
- )
- if no_attributes:
- return stmt
- return stmt.outerjoin(
- StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
- )
-
-
-def _get_oldest_possible_ts(
- hass: HomeAssistant, utc_point_in_time: datetime
-) -> float | None:
- """Return the oldest possible timestamp.
-
- Returns None if there are no states as old as utc_point_in_time.
- """
-
- oldest_ts = get_instance(hass).states_manager.oldest_ts
- if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp():
- return oldest_ts
- return None
-
-
-def _get_start_time_state_stmt(
- epoch_time: float,
- single_metadata_id: int | None,
- metadata_ids: list[int],
- no_attributes: bool,
- include_last_changed: bool,
- slow_dependent_subquery: bool,
-) -> Select:
- """Return the states at a specific point in time."""
- if single_metadata_id:
- # Use an entirely different (and extremely fast) query if we only
- # have a single entity id
- return _get_single_entity_start_time_stmt(
- epoch_time,
- single_metadata_id,
- no_attributes,
- include_last_changed,
- False,
- )
- # We have more than one entity to look at so we need to do a query on states
- # since the last recorder run started.
- if slow_dependent_subquery:
- return _get_start_time_state_for_entities_stmt_group_by(
- epoch_time,
- metadata_ids,
- no_attributes,
- include_last_changed,
- )
-
- return _get_start_time_state_for_entities_stmt_dependent_sub_query(
- epoch_time,
- metadata_ids,
- no_attributes,
- include_last_changed,
- )
-
-
-def _get_single_entity_start_time_stmt(
- epoch_time: float,
- metadata_id: int,
- no_attributes: bool,
- include_last_changed: bool,
- include_last_reported: bool,
-) -> Select:
- # Use an entirely different (and extremely fast) query if we only
- # have a single entity id
- stmt = (
- _stmt_and_join_attributes_for_start_state(
- no_attributes, include_last_changed, include_last_reported
- )
- .filter(
- States.last_updated_ts < epoch_time,
- States.metadata_id == metadata_id,
- )
- .order_by(States.last_updated_ts.desc())
- .limit(1)
- )
- if no_attributes:
- return stmt
- return stmt.outerjoin(
- StateAttributes, States.attributes_id == StateAttributes.attributes_id
- )
-
-
-def _sorted_states_to_dict(
- states: Iterable[Row],
- start_time_ts: float | None,
- entity_ids: list[str],
- entity_id_to_metadata_id: dict[str, int | None],
- minimal_response: bool = False,
- compressed_state_format: bool = False,
- descending: bool = False,
- no_attributes: bool = False,
-) -> dict[str, list[State | dict[str, Any]]]:
- """Convert SQL results into JSON friendly data structure.
-
- This takes our state list and turns it into a JSON friendly data
- structure {'entity_id': [list of states], 'entity_id2': [list of states]}
-
- States must be sorted by entity_id and last_updated
-
- We also need to go back and create a synthetic zero data point for
- each list of states, otherwise our graphs won't start on the Y
- axis correctly.
- """
- field_map = _FIELD_MAP
- state_class: Callable[
- [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool],
- State | dict[str, Any],
- ]
- if compressed_state_format:
- state_class = row_to_compressed_state
- attr_time = COMPRESSED_STATE_LAST_UPDATED
- attr_state = COMPRESSED_STATE_STATE
- else:
- state_class = LazyState
- attr_time = LAST_CHANGED_KEY
- attr_state = STATE_KEY
-
- # Set all entity IDs to empty lists in result set to maintain the order
- result: dict[str, list[State | dict[str, Any]]] = {
- entity_id: [] for entity_id in entity_ids
- }
- metadata_id_to_entity_id: dict[int, str] = {}
- metadata_id_to_entity_id = {
- v: k for k, v in entity_id_to_metadata_id.items() if v is not None
- }
- # Get the states at the start time
- if len(entity_ids) == 1:
- metadata_id = entity_id_to_metadata_id[entity_ids[0]]
- assert metadata_id is not None # should not be possible if we got here
- states_iter: Iterable[tuple[int, Iterator[Row]]] = (
- (metadata_id, iter(states)),
- )
- else:
- key_func = itemgetter(field_map["metadata_id"])
- states_iter = groupby(states, key_func)
-
- state_idx = field_map["state"]
- last_updated_ts_idx = field_map["last_updated_ts"]
-
- # Append all changes to it
- for metadata_id, group in states_iter:
- entity_id = metadata_id_to_entity_id[metadata_id]
- attr_cache: dict[str, dict[str, Any]] = {}
- ent_results = result[entity_id]
- if (
- not minimal_response
- or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS
- ):
- ent_results.extend(
- [
- state_class(
- db_state,
- attr_cache,
- start_time_ts,
- entity_id,
- db_state[state_idx],
- db_state[last_updated_ts_idx],
- False,
- )
- for db_state in group
- ]
- )
- continue
-
- prev_state: str | None = None
- # With minimal response we only provide a native
- # State for the first and last response. All the states
- # in-between only provide the "state" and the
- # "last_changed".
- if not ent_results:
- if (first_state := next(group, None)) is None:
- continue
- prev_state = first_state[state_idx]
- ent_results.append(
- state_class(
- first_state,
- attr_cache,
- start_time_ts,
- entity_id,
- prev_state,
- first_state[last_updated_ts_idx],
- no_attributes,
- )
- )
-
- #
- # minimal_response only makes sense with last_updated == last_updated
- #
- # We use last_updated for for last_changed since its the same
- #
- # With minimal response we do not care about attribute
- # changes so we can filter out duplicate states
- if compressed_state_format:
- # Compressed state format uses the timestamp directly
- ent_results.extend(
- [
- {
- attr_state: (prev_state := state),
- attr_time: row[last_updated_ts_idx],
- }
- for row in group
- if (state := row[state_idx]) != prev_state
- ]
- )
- continue
-
- # Non-compressed state format returns an ISO formatted string
- _utc_from_timestamp = dt_util.utc_from_timestamp
- ent_results.extend(
- [
- {
- attr_state: (prev_state := state),
- attr_time: _utc_from_timestamp(
- row[last_updated_ts_idx]
- ).isoformat(),
- }
- for row in group
- if (state := row[state_idx]) != prev_state
- ]
- )
-
- if descending:
- for ent_results in result.values():
- ent_results.reverse()
-
- # Filter out the empty lists if some states had 0 results.
- return {key: val for key, val in result.items() if val}
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index cc6a6979817..a1a9ac1bc64 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -8,7 +8,7 @@
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.41",
- "fnv-hash-fast==1.5.0",
+ "fnv-hash-fast==1.6.0",
"psutil-home-assistant==0.0.1"
]
}
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index 58af15c2aa7..1c53b528141 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -117,10 +117,10 @@ from .util import (
if TYPE_CHECKING:
from . import Recorder
-# Live schema migration supported starting from schema version 42 or newer
-# Schema version 41 was introduced in HA Core 2023.4
-# Schema version 42 was introduced in HA Core 2023.11
-LIVE_MIGRATION_MIN_SCHEMA_VERSION = 42
+# Live schema migration supported starting from schema version 48 or newer
+# Schema version 47 was introduced in HA Core 2024.9
+# Schema version 48 was introduced in HA Core 2025.1
+LIVE_MIGRATION_MIN_SCHEMA_VERSION = 48
MIGRATION_NOTE_OFFLINE = (
"Note: this may take several hours on large databases and slow machines. "
diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py
deleted file mode 100644
index 11ea9141fc0..00000000000
--- a/homeassistant/components/recorder/models/legacy.py
+++ /dev/null
@@ -1,167 +0,0 @@
-"""Models for Recorder."""
-
-from __future__ import annotations
-
-from datetime import datetime
-from typing import Any
-
-from sqlalchemy.engine.row import Row
-
-from homeassistant.const import (
- COMPRESSED_STATE_ATTRIBUTES,
- COMPRESSED_STATE_LAST_CHANGED,
- COMPRESSED_STATE_LAST_UPDATED,
- COMPRESSED_STATE_STATE,
-)
-from homeassistant.core import Context, State
-from homeassistant.util import dt as dt_util
-
-from .state_attributes import decode_attributes_from_source
-from .time import process_timestamp
-
-
-class LegacyLazyState(State):
- """A lazy version of core State after schema 31."""
-
- __slots__ = [
- "_attributes",
- "_context",
- "_last_changed_ts",
- "_last_reported_ts",
- "_last_updated_ts",
- "_row",
- "attr_cache",
- ]
-
- def __init__( # pylint: disable=super-init-not-called
- self,
- row: Row,
- attr_cache: dict[str, dict[str, Any]],
- start_time: datetime | None,
- entity_id: str | None = None,
- ) -> None:
- """Init the lazy state."""
- self._row = row
- self.entity_id = entity_id or self._row.entity_id
- self.state = self._row.state or ""
- self._attributes: dict[str, Any] | None = None
- self._last_updated_ts: float | None = self._row.last_updated_ts or (
- start_time.timestamp() if start_time else None
- )
- self._last_changed_ts: float | None = (
- self._row.last_changed_ts or self._last_updated_ts
- )
- self._last_reported_ts: float | None = self._last_updated_ts
- self._context: Context | None = None
- self.attr_cache = attr_cache
-
- @property # type: ignore[override]
- def attributes(self) -> dict[str, Any]:
- """State attributes."""
- if self._attributes is None:
- self._attributes = decode_attributes_from_row_legacy(
- self._row, self.attr_cache
- )
- return self._attributes
-
- @attributes.setter
- def attributes(self, value: dict[str, Any]) -> None:
- """Set attributes."""
- self._attributes = value
-
- @property
- def context(self) -> Context:
- """State context."""
- if self._context is None:
- self._context = Context(id=None)
- return self._context
-
- @context.setter
- def context(self, value: Context) -> None:
- """Set context."""
- self._context = value
-
- @property
- def last_changed(self) -> datetime:
- """Last changed datetime."""
- assert self._last_changed_ts is not None
- return dt_util.utc_from_timestamp(self._last_changed_ts)
-
- @last_changed.setter
- def last_changed(self, value: datetime) -> None:
- """Set last changed datetime."""
- self._last_changed_ts = process_timestamp(value).timestamp()
-
- @property
- def last_reported(self) -> datetime:
- """Last reported datetime."""
- assert self._last_reported_ts is not None
- return dt_util.utc_from_timestamp(self._last_reported_ts)
-
- @last_reported.setter
- def last_reported(self, value: datetime) -> None:
- """Set last reported datetime."""
- self._last_reported_ts = process_timestamp(value).timestamp()
-
- @property
- def last_updated(self) -> datetime:
- """Last updated datetime."""
- assert self._last_updated_ts is not None
- return dt_util.utc_from_timestamp(self._last_updated_ts)
-
- @last_updated.setter
- def last_updated(self, value: datetime) -> None:
- """Set last updated datetime."""
- self._last_updated_ts = process_timestamp(value).timestamp()
-
- def as_dict(self) -> dict[str, Any]: # type: ignore[override]
- """Return a dict representation of the LazyState.
-
- Async friendly.
- To be used for JSON serialization.
- """
- last_updated_isoformat = self.last_updated.isoformat()
- if self._last_changed_ts == self._last_updated_ts:
- last_changed_isoformat = last_updated_isoformat
- else:
- last_changed_isoformat = self.last_changed.isoformat()
- return {
- "entity_id": self.entity_id,
- "state": self.state,
- "attributes": self._attributes or self.attributes,
- "last_changed": last_changed_isoformat,
- "last_updated": last_updated_isoformat,
- }
-
-
-def legacy_row_to_compressed_state(
- row: Row,
- attr_cache: dict[str, dict[str, Any]],
- start_time: datetime | None,
- entity_id: str | None = None,
-) -> dict[str, Any]:
- """Convert a database row to a compressed state schema 31 and later."""
- comp_state = {
- COMPRESSED_STATE_STATE: row.state,
- COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache),
- }
- if start_time:
- comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp()
- else:
- row_last_updated_ts: float = row.last_updated_ts
- comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts
- if (
- row_last_changed_ts := row.last_changed_ts
- ) and row_last_updated_ts != row_last_changed_ts:
- comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts
- return comp_state
-
-
-def decode_attributes_from_row_legacy(
- row: Row, attr_cache: dict[str, dict[str, Any]]
-) -> dict[str, Any]:
- """Decode attributes from a database row."""
- return decode_attributes_from_source(
- getattr(row, "shared_attrs", None) or getattr(row, "attributes", None),
- attr_cache,
- )
diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py
index 08da12d6b17..be216923892 100644
--- a/homeassistant/components/recorder/models/statistics.py
+++ b/homeassistant/components/recorder/models/statistics.py
@@ -78,6 +78,7 @@ class CalendarStatisticPeriod(TypedDict, total=False):
period: Literal["hour", "day", "week", "month", "year"]
offset: int
+ first_weekday: Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
class FixedStatisticPeriod(TypedDict, total=False):
diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py
index ea2b93efba7..6b6c2c2c365 100644
--- a/homeassistant/components/recorder/purge.py
+++ b/homeassistant/components/recorder/purge.py
@@ -116,9 +116,7 @@ def purge_old_data(
# This purge cycle is finished, clean up old event types and
# recorder runs
_purge_old_event_types(instance, session)
-
- if instance.states_meta_manager.active:
- _purge_old_entity_ids(instance, session)
+ _purge_old_entity_ids(instance, session)
_purge_old_recorder_runs(instance, session, purge_before)
with session_scope(session=instance.get_session(), read_only=True) as session:
diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py
index ca92a2131d8..4e38d1f0a4d 100644
--- a/homeassistant/components/recorder/services.py
+++ b/homeassistant/components/recorder/services.py
@@ -89,8 +89,7 @@ SERVICE_GET_STATISTICS_SCHEMA = vol.Schema(
async def _async_handle_purge_service(service: ServiceCall) -> None:
"""Handle calls to the purge service."""
- hass = service.hass
- instance = hass.data[DATA_INSTANCE]
+ instance = service.hass.data[DATA_INSTANCE]
kwargs = service.data
keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days)
repack = cast(bool, kwargs[ATTR_REPACK])
@@ -101,14 +100,15 @@ async def _async_handle_purge_service(service: ServiceCall) -> None:
async def _async_handle_purge_entities_service(service: ServiceCall) -> None:
"""Handle calls to the purge entities service."""
- hass = service.hass
- entity_ids = await async_extract_entity_ids(hass, service)
+ entity_ids = await async_extract_entity_ids(service)
domains = service.data.get(ATTR_DOMAINS, [])
keep_days = service.data.get(ATTR_KEEP_DAYS, 0)
entity_globs = service.data.get(ATTR_ENTITY_GLOBS, [])
entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs)
purge_before = dt_util.utcnow() - timedelta(days=keep_days)
- hass.data[DATA_INSTANCE].queue_task(PurgeEntitiesTask(entity_filter, purge_before))
+ service.hass.data[DATA_INSTANCE].queue_task(
+ PurgeEntitiesTask(entity_filter, purge_before)
+ )
async def _async_handle_enable_service(service: ServiceCall) -> None:
diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py
index 75afb6589a1..0ea2c7415b9 100644
--- a/homeassistant/components/recorder/table_managers/states_meta.py
+++ b/homeassistant/components/recorder/table_managers/states_meta.py
@@ -24,8 +24,6 @@ CACHE_SIZE = 8192
class StatesMetaManager(BaseLRUTableManager[StatesMeta]):
"""Manage the StatesMeta table."""
- active = True
-
def __init__(self, recorder: Recorder) -> None:
"""Initialize the states meta manager."""
self._did_first_load = False
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
index cff3e868def..53beb6b43c2 100644
--- a/homeassistant/components/recorder/util.py
+++ b/homeassistant/components/recorder/util.py
@@ -27,6 +27,7 @@ from sqlalchemy.orm.session import Session
from sqlalchemy.sql.lambdas import StatementLambdaElement
import voluptuous as vol
+from homeassistant.const import WEEKDAYS
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.recorder import ( # noqa: F401
@@ -109,9 +110,7 @@ SUNDAY_WEEKDAY = 6
DAYS_IN_WEEK = 7
-def execute(
- qry: Query, to_native: bool = False, validate_entity_ids: bool = True
-) -> list[Row]:
+def execute(qry: Query) -> list[Row]:
"""Query the database and convert the objects to HA native form.
This method also retries a few times in the case of stale connections.
@@ -121,33 +120,15 @@ def execute(
try:
if debug:
timer_start = time.perf_counter()
-
- if to_native:
- result = [
- row
- for row in (
- row.to_native(validate_entity_id=validate_entity_ids)
- for row in qry
- )
- if row is not None
- ]
- else:
- result = qry.all()
+ result = qry.all()
if debug:
elapsed = time.perf_counter() - timer_start
- if to_native:
- _LOGGER.debug(
- "converting %d rows to native objects took %fs",
- len(result),
- elapsed,
- )
- else:
- _LOGGER.debug(
- "querying %d rows took %fs",
- len(result),
- elapsed,
- )
+ _LOGGER.debug(
+ "querying %d rows took %fs",
+ len(result),
+ elapsed,
+ )
except SQLAlchemyError as err:
_LOGGER.error("Error executing query: %s", err)
@@ -802,6 +783,7 @@ PERIOD_SCHEMA = vol.Schema(
{
vol.Required("period"): vol.Any("hour", "day", "week", "month", "year"),
vol.Optional("offset"): int,
+ vol.Optional("first_weekday"): vol.Any(*WEEKDAYS),
}
),
vol.Exclusive("fixed_period", "period"): vol.Schema(
@@ -840,7 +822,12 @@ def resolve_period(
start_time += timedelta(days=cal_offset)
end_time = start_time + timedelta(days=1)
elif calendar_period == "week":
- start_time = start_of_day - timedelta(days=start_of_day.weekday())
+ first_weekday = WEEKDAYS.index(
+ period_def["calendar"].get("first_weekday", WEEKDAYS[0])
+ )
+ start_time = start_of_day - timedelta(
+ days=(start_of_day.weekday() - first_weekday) % 7
+ )
start_time += timedelta(days=cal_offset * 7)
end_time = start_time + timedelta(weeks=1)
elif calendar_period == "month":
diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json
index 09b270b9687..0c6cf98de7f 100644
--- a/homeassistant/components/remote/strings.json
+++ b/homeassistant/components/remote/strings.json
@@ -14,6 +14,9 @@
"changed_states": "[%key:common::device_automation::trigger_type::changed_states%]",
"turned_on": "[%key:common::device_automation::trigger_type::turned_on%]",
"turned_off": "[%key:common::device_automation::trigger_type::turned_off%]"
+ },
+ "extra_fields": {
+ "for": "[%key:common::device_automation::extra_fields::for%]"
}
},
"entity_component": {
diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json
index 9fe01c5b952..82b6f82867d 100644
--- a/homeassistant/components/renault/manifest.json
+++ b/homeassistant/components/renault/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
- "requirements": ["renault-api==0.4.0"]
+ "requirements": ["renault-api==0.4.1"]
}
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index 42a29ee6ef4..a10a926f6e5 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -25,7 +25,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -322,7 +322,7 @@ async def async_remove_config_entry_device(
) -> bool:
"""Remove a device from a config entry."""
host: ReolinkHost = config_entry.runtime_data.host
- (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
+ (_device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
if is_chime:
await host.api.get_state(cmd="GetDingDongList")
@@ -431,7 +431,9 @@ def migrate_entity_ids(
if (DOMAIN, host.unique_id) in device.identifiers:
remove_ids = True # NVR/Hub in identifiers, keep that one, remove others
for old_id in device.identifiers:
- (old_device_uid, old_ch, old_is_chime) = get_device_uid_and_ch(old_id, host)
+ (old_device_uid, _old_ch, _old_is_chime) = get_device_uid_and_ch(
+ old_id, host
+ )
if (
not old_device_uid
or old_device_uid[0] != host.unique_id
@@ -495,16 +497,6 @@ def migrate_entity_ids(
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
for entity in entities:
- # Can be removed in HA 2025.1.0
- if entity.domain == "update" and entity.unique_id in [
- host.unique_id,
- format_mac(host.api.mac_address),
- ]:
- entity_reg.async_update_entity(
- entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
- )
- continue
-
if host.api.supported(None, "UID") and not entity.unique_id.startswith(
host.unique_id
):
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index 5664bba25a3..396d26421ce 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -74,21 +74,28 @@ BINARY_PUSH_SENSORS = (
),
ReolinkBinarySensorEntityDescription(
key=PERSON_DETECTION_TYPE,
- cmd_id=33,
+ cmd_id=[33, 600, 696],
translation_key="person",
value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE),
),
ReolinkBinarySensorEntityDescription(
key=VEHICLE_DETECTION_TYPE,
- cmd_id=33,
+ cmd_id=[33, 600, 696],
translation_key="vehicle",
value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE),
),
+ ReolinkBinarySensorEntityDescription(
+ key="non-motor_vehicle",
+ cmd_id=[600, 696],
+ translation_key="non-motor_vehicle",
+ value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"),
+ supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"),
+ ),
ReolinkBinarySensorEntityDescription(
key=PET_DETECTION_TYPE,
- cmd_id=33,
+ cmd_id=[33, 600, 696],
translation_key="pet",
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: (
@@ -98,14 +105,14 @@ BINARY_PUSH_SENSORS = (
),
ReolinkBinarySensorEntityDescription(
key=PET_DETECTION_TYPE,
- cmd_id=33,
+ cmd_id=[33, 600, 696],
translation_key="animal",
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: api.supported(ch, "ai_animal"),
),
ReolinkBinarySensorEntityDescription(
key=PACKAGE_DETECTION_TYPE,
- cmd_id=33,
+ cmd_id=[33, 600, 696],
translation_key="package",
value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE),
@@ -120,7 +127,7 @@ BINARY_PUSH_SENSORS = (
),
ReolinkBinarySensorEntityDescription(
key="cry",
- cmd_id=33,
+ cmd_id=[33],
translation_key="cry",
value=lambda api, ch: api.ai_detected(ch, "cry"),
supported=lambda api, ch: api.ai_supported(ch, "cry"),
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index 971b7ec4be1..c180e5f77b2 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -243,8 +243,45 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
await super().async_will_remove_from_hass()
+class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity):
+ """Parent class for Reolink chime entities connected to a Host."""
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ coordinator: DataUpdateCoordinator[None] | None = None,
+ ) -> None:
+ """Initialize ReolinkHostChimeCoordinatorEntity for a chime."""
+ super().__init__(reolink_data, coordinator)
+ self._channel = chime.channel
+ self._chime = chime
+
+ self._attr_unique_id = (
+ f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
+ )
+ via_dev_id = self._host.unique_id
+ self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._dev_id)},
+ via_device=(DOMAIN, via_dev_id),
+ name=chime.name,
+ model="Reolink Chime",
+ manufacturer=self._host.api.manufacturer,
+ sw_version=chime.sw_version,
+ serial_number=str(chime.dev_id),
+ configuration_url=self._conf_url,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return super().available and self._chime.online
+
+
class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
- """Parent class for Reolink chime entities connected."""
+ """Parent class for Reolink chime entities connected through a camera."""
def __init__(
self,
@@ -253,22 +290,23 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
+ assert chime.channel is not None
super().__init__(reolink_data, chime.channel, coordinator)
-
self._chime = chime
self._attr_unique_id = (
f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
)
- cam_dev_id = self._dev_id
+ via_dev_id = self._dev_id
self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
- via_device=(DOMAIN, cam_dev_id),
+ via_device=(DOMAIN, via_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
+ sw_version=chime.sw_version,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index 597a3372400..13775b5c58f 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -13,6 +13,12 @@
"on": "mdi:car"
}
},
+ "non-motor_vehicle": {
+ "default": "mdi:motorbike-off",
+ "state": {
+ "on": "mdi:motorbike"
+ }
+ },
"pet": {
"default": "mdi:dog-side-off",
"state": {
@@ -172,15 +178,36 @@
"floodlight_brightness": {
"default": "mdi:spotlight-beam"
},
+ "floodlight_event_brightness": {
+ "default": "mdi:spotlight-beam"
+ },
"ir_brightness": {
"default": "mdi:led-off"
},
+ "floodlight_event_on_time": {
+ "default": "mdi:spotlight-beam"
+ },
+ "floodlight_event_flash_time": {
+ "default": "mdi:spotlight-beam"
+ },
"volume": {
"default": "mdi:volume-high",
"state": {
"0": "mdi:volume-off"
}
},
+ "volume_speak": {
+ "default": "mdi:volume-high",
+ "state": {
+ "0": "mdi:volume-off"
+ }
+ },
+ "volume_doorbell": {
+ "default": "mdi:volume-high",
+ "state": {
+ "0": "mdi:volume-off"
+ }
+ },
"alarm_volume": {
"default": "mdi:volume-high",
"state": {
@@ -211,6 +238,9 @@
"ai_vehicle_sensitivity": {
"default": "mdi:car"
},
+ "ai_non_motor_vehicle_sensitivity": {
+ "default": "mdi:bicycle"
+ },
"ai_package_sensitivity": {
"default": "mdi:gift-outline"
},
@@ -247,6 +277,9 @@
"ai_vehicle_delay": {
"default": "mdi:car"
},
+ "ai_non_motor_vehicle_delay": {
+ "default": "mdi:bicycle"
+ },
"ai_package_delay": {
"default": "mdi:gift-outline"
},
@@ -306,12 +339,18 @@
},
"pre_record_battery_stop": {
"default": "mdi:history"
+ },
+ "silent_time": {
+ "default": "mdi:volume-off"
}
},
"select": {
"floodlight_mode": {
"default": "mdi:spotlight-beam"
},
+ "floodlight_event_mode": {
+ "default": "mdi:spotlight-beam"
+ },
"day_night_mode": {
"default": "mdi:theme-light-dark"
},
@@ -390,6 +429,12 @@
"sub_bit_rate": {
"default": "mdi:play-speed"
},
+ "main_encoding": {
+ "default": "mdi:video-image"
+ },
+ "sub_encoding": {
+ "default": "mdi:video-image"
+ },
"scene_mode": {
"default": "mdi:view-list"
},
@@ -435,6 +480,15 @@
},
"sd_storage": {
"default": "mdi:micro-sd"
+ },
+ "person_type": {
+ "default": "mdi:account"
+ },
+ "vehicle_type": {
+ "default": "mdi:car"
+ },
+ "animal_type": {
+ "default": "mdi:paw"
}
},
"siren": {
diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py
index 1e2c6d49528..a5826e9bb8c 100644
--- a/homeassistant/components/reolink/light.py
+++ b/homeassistant/components/reolink/light.py
@@ -7,9 +7,11 @@ from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Host
+from reolink_aio.const import MAX_COLOR_TEMP, MIN_COLOR_TEMP
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP_KELVIN,
ColorMode,
LightEntity,
LightEntityDescription,
@@ -37,8 +39,10 @@ class ReolinkLightEntityDescription(
"""A class that describes light entities."""
get_brightness_fn: Callable[[Host, int], int | None] | None = None
+ get_color_temp_fn: Callable[[Host, int], int | None] | None = None
is_on_fn: Callable[[Host, int], bool]
set_brightness_fn: Callable[[Host, int, int], Any] | None = None
+ set_color_temp_fn: Callable[[Host, int, int], Any] | None = None
turn_on_off_fn: Callable[[Host, int, bool], Any]
@@ -64,6 +68,10 @@ LIGHT_ENTITIES = (
turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value),
get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch),
set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value),
+ get_color_temp_fn=lambda api, ch: api.whiteled_color_temperature(ch),
+ set_color_temp_fn=lambda api, ch, value: (
+ api.baichuan.set_floodlight(ch, color_temp=value)
+ ),
),
ReolinkLightEntityDescription(
key="status_led",
@@ -127,12 +135,20 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
self.entity_description = entity_description
super().__init__(reolink_data, channel)
- if entity_description.set_brightness_fn is None:
- self._attr_supported_color_modes = {ColorMode.ONOFF}
- self._attr_color_mode = ColorMode.ONOFF
- else:
+ if (
+ entity_description.set_color_temp_fn is not None
+ and self._host.api.supported(self._channel, "color_temp")
+ ):
+ self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
+ self._attr_color_mode = ColorMode.COLOR_TEMP
+ self._attr_min_color_temp_kelvin = MIN_COLOR_TEMP
+ self._attr_max_color_temp_kelvin = MAX_COLOR_TEMP
+ elif entity_description.set_brightness_fn is not None:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._attr_color_mode = ColorMode.BRIGHTNESS
+ else:
+ self._attr_supported_color_modes = {ColorMode.ONOFF}
+ self._attr_color_mode = ColorMode.ONOFF
@property
def is_on(self) -> bool:
@@ -152,6 +168,13 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
return round(255 * bright_pct / 100.0)
+ @property
+ def color_temp_kelvin(self) -> int | None:
+ """Return the color temperature of this light in kelvin."""
+ assert self.entity_description.get_color_temp_fn is not None
+
+ return self.entity_description.get_color_temp_fn(self._host.api, self._channel)
+
@raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off."""
@@ -171,6 +194,13 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
self._host.api, self._channel, brightness_pct
)
+ if (
+ color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN)
+ ) is not None and self.entity_description.set_color_temp_fn is not None:
+ await self.entity_description.set_color_temp_fn(
+ self._host.api, self._channel, color_temp
+ )
+
await self.entity_description.turn_on_off_fn(
self._host.api, self._channel, True
)
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 4ad80dda807..116c2928ff3 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
- "requirements": ["reolink-aio==0.14.6"]
+ "requirements": ["reolink-aio==0.16.1"]
}
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index da879194e88..eee0dab81fe 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -23,6 +23,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
+ ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -124,6 +125,22 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.whiteled_brightness(ch),
method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)),
),
+ ReolinkNumberEntityDescription(
+ key="floodlight_event_brightness",
+ cmd_key="GetWhiteLed",
+ cmd_id=[289, 438],
+ translation_key="floodlight_event_brightness",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_min_value=1,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "floodlight_event"),
+ value=lambda api, ch: api.whiteled_event_brightness(ch),
+ method=lambda api, ch, value: (
+ api.baichuan.set_floodlight(ch, event_brightness=int(value))
+ ),
+ ),
ReolinkNumberEntityDescription(
key="ir_brightness",
cmd_key="208",
@@ -138,6 +155,42 @@ NUMBER_ENTITIES = (
api.baichuan.set_status_led(ch, ir_brightness=int(value))
),
),
+ ReolinkNumberEntityDescription(
+ key="floodlight_event_on_time",
+ cmd_key="GetWhiteLed",
+ cmd_id=[289, 438],
+ translation_key="floodlight_event_on_time",
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=30,
+ native_max_value=900,
+ supported=lambda api, ch: api.supported(ch, "floodlight_event"),
+ value=lambda api, ch: api.whiteled_event_on_time(ch),
+ method=lambda api, ch, value: (
+ api.baichuan.set_floodlight(ch, event_on_time=int(value))
+ ),
+ ),
+ ReolinkNumberEntityDescription(
+ key="floodlight_event_flash_time",
+ cmd_key="GetWhiteLed",
+ cmd_id=[289, 438],
+ translation_key="floodlight_event_flash_time",
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=10,
+ native_max_value=30,
+ supported=lambda api, ch: api.supported(ch, "floodlight_event"),
+ value=lambda api, ch: api.whiteled_event_flash_time(ch),
+ method=lambda api, ch, value: (
+ api.baichuan.set_floodlight(ch, event_flash_time=int(value))
+ ),
+ ),
ReolinkNumberEntityDescription(
key="volume",
cmd_key="GetAudioCfg",
@@ -150,6 +203,30 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.volume(ch),
method=lambda api, ch, value: api.set_volume(ch, volume=int(value)),
),
+ ReolinkNumberEntityDescription(
+ key="volume_speak",
+ cmd_key="GetAudioCfg",
+ translation_key="volume_speak",
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "volume_speak"),
+ value=lambda api, ch: api.volume_speak(ch),
+ method=lambda api, ch, value: api.set_volume(ch, volume_speak=int(value)),
+ ),
+ ReolinkNumberEntityDescription(
+ key="volume_doorbell",
+ cmd_key="GetAudioCfg",
+ translation_key="volume_doorbell",
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "volume_doorbell"),
+ value=lambda api, ch: api.volume_doorbell(ch),
+ method=lambda api, ch, value: api.set_volume(ch, volume_doorbell=int(value)),
+ ),
ReolinkNumberEntityDescription(
key="guard_return_time",
cmd_key="GetPtzGuard",
@@ -230,6 +307,23 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.ai_sensitivity(ch, "vehicle"),
method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "vehicle"),
),
+ ReolinkNumberEntityDescription(
+ key="ai_non_motor_vehicle_sensitivity",
+ cmd_key="GetAiAlarm",
+ translation_key="ai_non_motor_vehicle_sensitivity",
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: (
+ api.supported(ch, "ai_sensitivity")
+ and api.supported(ch, "ai_non-motor vehicle")
+ ),
+ value=lambda api, ch: api.ai_sensitivity(ch, "non-motor vehicle"),
+ method=lambda api, ch, value: (
+ api.set_ai_sensitivity(ch, int(value), "non-motor vehicle")
+ ),
+ ),
ReolinkNumberEntityDescription(
key="ai_package_sensititvity",
cmd_key="GetAiAlarm",
@@ -320,6 +414,25 @@ NUMBER_ENTITIES = (
value=lambda api, ch: api.ai_delay(ch, "people"),
method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "people"),
),
+ ReolinkNumberEntityDescription(
+ key="ai_non_motor_vehicle_delay",
+ cmd_key="GetAiAlarm",
+ translation_key="ai_non_motor_vehicle_delay",
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=0,
+ native_max_value=8,
+ supported=lambda api, ch: (
+ api.supported(ch, "ai_delay") and api.supported(ch, "ai_non-motor vehicle")
+ ),
+ value=lambda api, ch: api.ai_delay(ch, "non-motor vehicle"),
+ method=lambda api, ch, value: (
+ api.set_ai_delay(ch, int(value), "non-motor vehicle")
+ ),
+ ),
ReolinkNumberEntityDescription(
key="ai_vehicle_delay",
cmd_key="GetAiAlarm",
@@ -478,7 +591,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_brightness",
cmd_key="GetImage",
- cmd_id=26,
+ cmd_id=[26, 78],
translation_key="image_brightness",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -492,7 +605,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_contrast",
cmd_key="GetImage",
- cmd_id=26,
+ cmd_id=[26, 78],
translation_key="image_contrast",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -506,7 +619,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_saturation",
cmd_key="GetImage",
- cmd_id=26,
+ cmd_id=[26, 78],
translation_key="image_saturation",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -520,7 +633,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_sharpness",
cmd_key="GetImage",
- cmd_id=26,
+ cmd_id=[26, 78],
translation_key="image_sharpness",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -534,7 +647,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="image_hue",
cmd_key="GetImage",
- cmd_id=26,
+ cmd_id=[26, 78],
translation_key="image_hue",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -780,6 +893,19 @@ CHIME_NUMBER_ENTITIES = (
value=lambda chime: chime.volume,
method=lambda chime, value: chime.set_option(volume=int(value)),
),
+ ReolinkChimeNumberEntityDescription(
+ key="silent_time",
+ cmd_key="609",
+ translation_key="silent_time",
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=720,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ value=lambda chime: int(chime.silent_time / 60),
+ method=lambda chime, value: chime.set_silent_time(time=int(value * 60)),
+ ),
)
@@ -816,6 +942,13 @@ async def async_setup_entry(
ReolinkChimeNumberEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_NUMBER_ENTITIES
for chime in api.chime_list
+ if chime.channel is not None
+ )
+ entities.extend(
+ ReolinkHostChimeNumberEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_NUMBER_ENTITIES
+ for chime in api.chime_list
+ if chime.channel is None
)
async_add_entities(entities)
@@ -931,7 +1064,36 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity):
class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
- """Base number entity class for Reolink IP cameras."""
+ """Base number entity class for Reolink chimes connected through a camera."""
+
+ entity_description: ReolinkChimeNumberEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeNumberEntityDescription,
+ ) -> None:
+ """Initialize Reolink chime number entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+
+ self._attr_mode = entity_description.mode
+
+ @property
+ def native_value(self) -> float | None:
+ """State of the number entity."""
+ return self.entity_description.value(self._chime)
+
+ @raise_translated_error
+ async def async_set_native_value(self, value: float) -> None:
+ """Update the current value."""
+ await self.entity_description.method(self._chime, value)
+ self.async_write_ha_state()
+
+
+class ReolinkHostChimeNumberEntity(ReolinkHostChimeCoordinatorEntity, NumberEntity):
+ """Base number entity class for Reolink chimes connected to the host."""
entity_description: ReolinkChimeNumberEntityDescription
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index 242ea784cd9..fc7f6e49eb5 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -12,9 +12,11 @@ from reolink_aio.api import (
Chime,
ChimeToneEnum,
DayNightEnum,
+ EncodingEnum,
HDREnum,
Host,
HubToneEnum,
+ SpotlightEventModeEnum,
SpotlightModeEnum,
StatusLedEnum,
TrackMethodEnum,
@@ -30,6 +32,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
+ ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -72,7 +75,7 @@ class ReolinkChimeSelectEntityDescription(
get_options: list[str]
method: Callable[[Chime, str], Any]
- value: Callable[[Chime], str]
+ value: Callable[[Chime], str | None]
def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
@@ -84,6 +87,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="floodlight_mode",
cmd_key="GetWhiteLed",
+ cmd_id=[289, 438],
translation_key="floodlight_mode",
entity_category=EntityCategory.CONFIG,
get_options=lambda api, ch: api.whiteled_mode_list(ch),
@@ -91,6 +95,21 @@ SELECT_ENTITIES = (
value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name,
method=lambda api, ch, name: api.set_whiteled(ch, mode=name),
),
+ ReolinkSelectEntityDescription(
+ key="floodlight_event_mode",
+ cmd_key="GetWhiteLed",
+ cmd_id=[289, 438],
+ translation_key="floodlight_event_mode",
+ entity_category=EntityCategory.CONFIG,
+ get_options=[mode.name for mode in SpotlightEventModeEnum],
+ supported=lambda api, ch: api.supported(ch, "floodlight_event"),
+ value=lambda api, ch: SpotlightEventModeEnum(api.whiteled_event_mode(ch)).name,
+ method=lambda api, ch, name: (
+ api.baichuan.set_floodlight(
+ ch, event_mode=SpotlightEventModeEnum[name].value
+ )
+ ),
+ ),
ReolinkSelectEntityDescription(
key="day_night_mode",
cmd_key="GetIsp",
@@ -250,6 +269,28 @@ SELECT_ENTITIES = (
value=lambda api, ch: str(api.bit_rate(ch, "sub")),
method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"),
),
+ ReolinkSelectEntityDescription(
+ key="main_encoding",
+ cmd_key="GetEnc",
+ translation_key="main_encoding",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ get_options=[val.name for val in EncodingEnum],
+ supported=lambda api, ch: api.supported(ch, "encoding"),
+ value=lambda api, ch: api.encoding(ch, "main"),
+ method=lambda api, ch, value: api.set_encoding(ch, value, "main"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="sub_encoding",
+ cmd_key="GetEnc",
+ translation_key="sub_encoding",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ get_options=[val.name for val in EncodingEnum],
+ supported=lambda api, ch: api.supported(ch, "encoding"),
+ value=lambda api, ch: api.encoding(ch, "sub"),
+ method=lambda api, ch, value: api.set_encoding(ch, value, "sub"),
+ ),
ReolinkSelectEntityDescription(
key="pre_record_fps",
cmd_key="594",
@@ -309,7 +350,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
supported=lambda chime: "md" in chime.chime_event_types,
get_options=[method.name for method in ChimeToneEnum],
- value=lambda chime: ChimeToneEnum(chime.tone("md")).name,
+ value=lambda chime: chime.tone_name("md"),
method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -319,7 +360,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "people" in chime.chime_event_types,
- value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
+ value=lambda chime: chime.tone_name("people"),
method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -329,7 +370,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "vehicle" in chime.chime_event_types,
- value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name,
+ value=lambda chime: chime.tone_name("vehicle"),
method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -339,7 +380,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "visitor" in chime.chime_event_types,
- value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name,
+ value=lambda chime: chime.tone_name("visitor"),
method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -349,7 +390,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "package" in chime.chime_event_types,
- value=lambda chime: ChimeToneEnum(chime.tone("package")).name,
+ value=lambda chime: chime.tone_name("package"),
method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value),
),
)
@@ -363,9 +404,7 @@ async def async_setup_entry(
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = config_entry.runtime_data
- entities: list[
- ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity
- ] = [
+ entities: list[SelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
@@ -380,7 +419,13 @@ async def async_setup_entry(
ReolinkChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES
for chime in reolink_data.host.api.chime_list
- if entity_description.supported(chime)
+ if entity_description.supported(chime) and chime.channel is not None
+ )
+ entities.extend(
+ ReolinkHostChimeSelectEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_SELECT_ENTITIES
+ for chime in reolink_data.host.api.chime_list
+ if entity_description.supported(chime) and chime.channel is None
)
async_add_entities(entities)
@@ -458,7 +503,7 @@ class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity):
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
- """Base select entity class for Reolink IP cameras."""
+ """Base select entity class for Reolink chimes connected through a camera."""
entity_description: ReolinkChimeSelectEntityDescription
@@ -471,22 +516,40 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Initialize Reolink select entity for a chime."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
- self._log_error = True
self._attr_options = entity_description.get_options
@property
def current_option(self) -> str | None:
"""Return the current option."""
- try:
- option = self.entity_description.value(self._chime)
- except (ValueError, KeyError):
- if self._log_error:
- _LOGGER.exception("Reolink '%s' has an unknown value", self.name)
- self._log_error = False
- return None
-
- self._log_error = True
- return option
+ return self.entity_description.value(self._chime)
+
+ @raise_translated_error
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.entity_description.method(self._chime, option)
+ self.async_write_ha_state()
+
+
+class ReolinkHostChimeSelectEntity(ReolinkHostChimeCoordinatorEntity, SelectEntity):
+ """Base select entity class for Reolink chimes connected to a host."""
+
+ entity_description: ReolinkChimeSelectEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeSelectEntityDescription,
+ ) -> None:
+ """Initialize Reolink select entity for a chime."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+ self._attr_options = entity_description.get_options
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current option."""
+ return self.entity_description.value(self._chime)
@raise_translated_error
async def async_select_option(self, option: str) -> None:
diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py
index 9b9a78c8ce7..fe9744543c0 100644
--- a/homeassistant/components/reolink/sensor.py
+++ b/homeassistant/components/reolink/sensor.py
@@ -8,6 +8,7 @@ from datetime import date, datetime
from decimal import Decimal
from reolink_aio.api import Host
+from reolink_aio.const import YOLO_DETECT_TYPES
from reolink_aio.enums import BatteryEnum
from homeassistant.components.sensor import (
@@ -135,11 +136,45 @@ SENSORS = (
value=lambda api, ch: api.wifi_signal(ch),
supported=lambda api, ch: api.supported(ch, "wifi"),
),
+ ReolinkSensorEntityDescription(
+ key="person_type",
+ cmd_id=696,
+ translation_key="person_type",
+ device_class=SensorDeviceClass.ENUM,
+ options=YOLO_DETECT_TYPES["people"],
+ value=lambda api, ch: api.baichuan.ai_detect_type(ch, "person"),
+ supported=lambda api, ch: (
+ api.supported(ch, "ai_yolo_type") and api.supported(ch, "ai_people")
+ ),
+ ),
+ ReolinkSensorEntityDescription(
+ key="vehicle_type",
+ cmd_id=696,
+ translation_key="vehicle_type",
+ device_class=SensorDeviceClass.ENUM,
+ options=YOLO_DETECT_TYPES["vehicle"],
+ value=lambda api, ch: api.baichuan.ai_detect_type(ch, "vehicle"),
+ supported=lambda api, ch: (
+ api.supported(ch, "ai_yolo_type") and api.supported(ch, "ai_vehicle")
+ ),
+ ),
+ ReolinkSensorEntityDescription(
+ key="animal_type",
+ cmd_id=696,
+ translation_key="animal_type",
+ device_class=SensorDeviceClass.ENUM,
+ options=YOLO_DETECT_TYPES["dog_cat"],
+ value=lambda api, ch: api.baichuan.ai_detect_type(ch, "dog_cat"),
+ supported=lambda api, ch: (
+ api.supported(ch, "ai_yolo_type") and api.supported(ch, "ai_dog_cat")
+ ),
+ ),
)
HOST_SENSORS = (
ReolinkHostSensorEntityDescription(
key="wifi_signal",
+ cmd_id=464,
cmd_key="115",
translation_key="wifi_signal",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
@@ -249,7 +284,7 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity):
class ReolinkHddSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity):
- """Base sensor class for Reolink host sensors."""
+ """Base sensor class for Reolink storage device sensors."""
entity_description: ReolinkSensorEntityDescription
@@ -259,7 +294,7 @@ class ReolinkHddSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity):
hdd_index: int,
entity_description: ReolinkSensorEntityDescription,
) -> None:
- """Initialize Reolink host sensor."""
+ """Initialize Reolink storage device sensor."""
self.entity_description = entity_description
super().__init__(reolink_data)
self._hdd_index = hdd_index
diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py
index 352ebb4ef19..33347170d11 100644
--- a/homeassistant/components/reolink/services.py
+++ b/homeassistant/components/reolink/services.py
@@ -46,7 +46,7 @@ async def _async_play_chime(service_call: ServiceCall) -> None:
translation_placeholders={"service_name": "play_chime"},
)
host: ReolinkHost = config_entry.runtime_data.host
- (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host)
+ (_device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host)
chime: Chime | None = host.api.chime(chime_id)
if not is_chime or chime is None:
raise ServiceValidationError(
diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py
index f5d2de977ae..cfd1f5f82f0 100644
--- a/homeassistant/components/reolink/siren.py
+++ b/homeassistant/components/reolink/siren.py
@@ -15,7 +15,12 @@ from homeassistant.components.siren import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
+from .entity import (
+ ReolinkChannelCoordinatorEntity,
+ ReolinkChannelEntityDescription,
+ ReolinkHostCoordinatorEntity,
+ ReolinkHostEntityDescription,
+)
from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
PARALLEL_UPDATES = 0
@@ -28,14 +33,30 @@ class ReolinkSirenEntityDescription(
"""A class that describes siren entities."""
+@dataclass(frozen=True)
+class ReolinkHostSirenEntityDescription(
+ SirenEntityDescription, ReolinkHostEntityDescription
+):
+ """A class that describes siren entities."""
+
+
SIREN_ENTITIES = (
ReolinkSirenEntityDescription(
key="siren",
+ cmd_id=547,
translation_key="siren",
supported=lambda api, ch: api.supported(ch, "siren_play"),
),
)
+HOST_SIREN_ENTITIES = (
+ ReolinkHostSirenEntityDescription(
+ key="siren",
+ translation_key="siren",
+ supported=lambda api: api.supported(None, "siren_play"),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -45,12 +66,18 @@ async def async_setup_entry(
"""Set up a Reolink siren entities."""
reolink_data: ReolinkData = config_entry.runtime_data
- async_add_entities(
+ entities: list[SirenEntity] = [
ReolinkSirenEntity(reolink_data, channel, entity_description)
for entity_description in SIREN_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
+ ]
+ entities.extend(
+ ReolinkHostSirenEntity(reolink_data, entity_description)
+ for entity_description in HOST_SIREN_ENTITIES
+ if entity_description.supported(reolink_data.host.api)
)
+ async_add_entities(entities)
class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity):
@@ -74,6 +101,11 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity):
self.entity_description = entity_description
super().__init__(reolink_data, channel)
+ @property
+ def is_on(self) -> bool | None:
+ """State of the siren."""
+ return self._host.api.baichuan.siren_state(self._channel)
+
@raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the siren."""
@@ -86,3 +118,29 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the siren."""
await self._host.api.set_siren(self._channel, False, None)
+
+
+class ReolinkHostSirenEntity(ReolinkHostCoordinatorEntity, SirenEntity):
+ """Base siren class for Reolink hub/NVR."""
+
+ _attr_supported_features = (
+ SirenEntityFeature.TURN_ON | SirenEntityFeature.VOLUME_SET
+ )
+ entity_description: ReolinkHostSirenEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ entity_description: ReolinkHostSirenEntityDescription,
+ ) -> None:
+ """Initialize Reolink host siren."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data)
+
+ @raise_translated_error
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the siren."""
+ if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
+ await self._host.api.set_hub_audio(alarm_volume=int(volume * 100))
+ else:
+ await self._host.api.set_siren()
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index 7e8bf94eeae..dda68c6b4ad 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -50,7 +50,7 @@
"protocol": "Protocol"
},
"data_description": {
- "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera."
+ "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (H.265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera."
}
}
}
@@ -132,10 +132,6 @@
"title": "Reolink firmware update required",
"description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})."
},
- "hub_switch_deprecated": {
- "title": "Reolink Home Hub switches deprecated",
- "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
- },
"password_too_long": {
"title": "Reolink password too long",
"description": "The password for \"{name}\" is more than 31 characters long, this is no longer compatible with the Reolink API. Please change the password using the Reolink app/client to a password with is shorter than 32 characters. After changing the password, fill in the new password in the Reolink Re-authentication flow to continue using this integration. The latest version of the Reolink app/client also has a password limit of 31 characters."
@@ -206,6 +202,13 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
+ "non-motor_vehicle": {
+ "name": "Bicycle",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
"pet": {
"name": "Pet",
"state": {
@@ -535,12 +538,27 @@
"floodlight_brightness": {
"name": "Floodlight turn on brightness"
},
+ "floodlight_event_brightness": {
+ "name": "Floodlight event brightness"
+ },
"ir_brightness": {
"name": "Infrared light brightness"
},
+ "floodlight_event_on_time": {
+ "name": "Floodlight event on time"
+ },
+ "floodlight_event_flash_time": {
+ "name": "Floodlight event flash time"
+ },
"volume": {
"name": "Volume"
},
+ "volume_speak": {
+ "name": "Speak volume"
+ },
+ "volume_doorbell": {
+ "name": "Doorbell volume"
+ },
"alarm_volume": {
"name": "Alarm volume"
},
@@ -565,6 +583,9 @@
"ai_vehicle_sensitivity": {
"name": "AI vehicle sensitivity"
},
+ "ai_non_motor_vehicle_sensitivity": {
+ "name": "AI bicycle sensitivity"
+ },
"ai_package_sensitivity": {
"name": "AI package sensitivity"
},
@@ -601,6 +622,9 @@
"ai_vehicle_delay": {
"name": "AI vehicle delay"
},
+ "ai_non_motor_vehicle_delay": {
+ "name": "AI bicycle delay"
+ },
"ai_package_delay": {
"name": "AI package delay"
},
@@ -660,6 +684,9 @@
},
"pre_record_battery_stop": {
"name": "Pre-recording stop battery level"
+ },
+ "silent_time": {
+ "name": "Silent time"
}
},
"select": {
@@ -674,6 +701,14 @@
"autoadaptive": "Auto adaptive"
}
},
+ "floodlight_event_mode": {
+ "name": "Floodlight event mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "flash": "Flash"
+ }
+ },
"day_night_mode": {
"name": "Day night mode",
"state": {
@@ -852,6 +887,12 @@
"sub_bit_rate": {
"name": "Fluent bit rate"
},
+ "main_encoding": {
+ "name": "Clear encoding"
+ },
+ "sub_encoding": {
+ "name": "Fluent encoding"
+ },
"scene_mode": {
"name": "Scene mode",
"state": {
@@ -908,6 +949,29 @@
},
"sd_storage": {
"name": "SD {hdd_index} storage"
+ },
+ "person_type": {
+ "name": "Person type",
+ "state": {
+ "man": "Man",
+ "woman": "Woman"
+ }
+ },
+ "vehicle_type": {
+ "name": "Vehicle type",
+ "state": {
+ "sedan": "Sedan",
+ "suv": "SUV",
+ "pickup_truck": "Pickup truck",
+ "motorcycle": "Motorcycle"
+ }
+ },
+ "animal_type": {
+ "name": "Animal type",
+ "state": {
+ "dog": "Dog",
+ "cat": "Cat"
+ }
}
},
"siren": {
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index 00934bc9777..b7d249b6fec 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -11,15 +11,14 @@ from reolink_aio.api import Chime, Host
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
+ ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -40,11 +39,11 @@ class ReolinkSwitchEntityDescription(
@dataclass(frozen=True, kw_only=True)
-class ReolinkNVRSwitchEntityDescription(
+class ReolinkHostSwitchEntityDescription(
SwitchEntityDescription,
ReolinkHostEntityDescription,
):
- """A class that describes NVR switch entities."""
+ """A class that describes host switch entities."""
method: Callable[[Host, bool], Any]
value: Callable[[Host], bool]
@@ -155,7 +154,7 @@ SWITCH_ENTITIES = (
cmd_key="GetRec",
translation_key="record",
entity_category=EntityCategory.CONFIG,
- supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr,
+ supported=lambda api, ch: api.supported(ch, "rec_enable") and api.is_nvr,
value=lambda api, ch: api.recording_enabled(ch),
method=lambda api, ch, value: api.set_recording(ch, value),
),
@@ -246,8 +245,8 @@ SWITCH_ENTITIES = (
),
)
-NVR_SWITCH_ENTITIES = (
- ReolinkNVRSwitchEntityDescription(
+HOST_SWITCH_ENTITIES = (
+ ReolinkHostSwitchEntityDescription(
key="email",
cmd_key="GetEmail",
translation_key="email",
@@ -256,7 +255,7 @@ NVR_SWITCH_ENTITIES = (
value=lambda api: api.email_enabled(),
method=lambda api, value: api.set_email(None, value),
),
- ReolinkNVRSwitchEntityDescription(
+ ReolinkHostSwitchEntityDescription(
key="ftp_upload",
cmd_key="GetFtp",
translation_key="ftp_upload",
@@ -265,7 +264,7 @@ NVR_SWITCH_ENTITIES = (
value=lambda api: api.ftp_enabled(),
method=lambda api, value: api.set_ftp(None, value),
),
- ReolinkNVRSwitchEntityDescription(
+ ReolinkHostSwitchEntityDescription(
key="push_notifications",
cmd_key="GetPush",
translation_key="push_notifications",
@@ -274,16 +273,16 @@ NVR_SWITCH_ENTITIES = (
value=lambda api: api.push_enabled(),
method=lambda api, value: api.set_push(None, value),
),
- ReolinkNVRSwitchEntityDescription(
+ ReolinkHostSwitchEntityDescription(
key="record",
cmd_key="GetRec",
translation_key="record",
entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.supported(None, "recording") and not api.is_hub,
+ supported=lambda api: api.supported(None, "rec_enable") and not api.is_hub,
value=lambda api: api.recording_enabled(),
method=lambda api, value: api.set_recording(None, value),
),
- ReolinkNVRSwitchEntityDescription(
+ ReolinkHostSwitchEntityDescription(
key="buzzer",
cmd_key="GetBuzzerAlarmV20",
translation_key="hub_ringtone_on_event",
@@ -305,56 +304,6 @@ CHIME_SWITCH_ENTITIES = (
),
)
-# Can be removed in HA 2025.4.0
-DEPRECATED_NVR_SWITCHES = [
- ReolinkNVRSwitchEntityDescription(
- key="email",
- cmd_key="GetEmail",
- translation_key="email",
- entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.is_hub,
- value=lambda api: api.email_enabled(),
- method=lambda api, value: api.set_email(None, value),
- ),
- ReolinkNVRSwitchEntityDescription(
- key="ftp_upload",
- cmd_key="GetFtp",
- translation_key="ftp_upload",
- entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.is_hub,
- value=lambda api: api.ftp_enabled(),
- method=lambda api, value: api.set_ftp(None, value),
- ),
- ReolinkNVRSwitchEntityDescription(
- key="push_notifications",
- cmd_key="GetPush",
- translation_key="push_notifications",
- entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.is_hub,
- value=lambda api: api.push_enabled(),
- method=lambda api, value: api.set_push(None, value),
- ),
- ReolinkNVRSwitchEntityDescription(
- key="record",
- cmd_key="GetRec",
- translation_key="record",
- entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.is_hub,
- value=lambda api: api.recording_enabled(),
- method=lambda api, value: api.set_recording(None, value),
- ),
- ReolinkNVRSwitchEntityDescription(
- key="buzzer",
- cmd_key="GetBuzzerAlarmV20",
- translation_key="hub_ringtone_on_event",
- icon="mdi:room-service",
- entity_category=EntityCategory.CONFIG,
- supported=lambda api: api.is_hub,
- value=lambda api: api.buzzer_enabled(),
- method=lambda api, value: api.set_buzzer(None, value),
- ),
-]
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -364,52 +313,29 @@ async def async_setup_entry(
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = config_entry.runtime_data
- entities: list[
- ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity
- ] = [
+ entities: list[SwitchEntity] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
- ReolinkNVRSwitchEntity(reolink_data, entity_description)
- for entity_description in NVR_SWITCH_ENTITIES
+ ReolinkHostSwitchEntity(reolink_data, entity_description)
+ for entity_description in HOST_SWITCH_ENTITIES
if entity_description.supported(reolink_data.host.api)
)
entities.extend(
ReolinkChimeSwitchEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SWITCH_ENTITIES
for chime in reolink_data.host.api.chime_list
+ if chime.channel is not None
+ )
+ entities.extend(
+ ReolinkHostChimeSwitchEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_SWITCH_ENTITIES
+ for chime in reolink_data.host.api.chime_list
+ if chime.channel is None
)
-
- # Can be removed in HA 2025.4.0
- depricated_dict = {}
- for desc in DEPRECATED_NVR_SWITCHES:
- if not desc.supported(reolink_data.host.api):
- continue
- depricated_dict[f"{reolink_data.host.unique_id}_{desc.key}"] = desc
-
- entity_reg = er.async_get(hass)
- reg_entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id)
- for entity in reg_entities:
- # Can be removed in HA 2025.4.0
- if entity.domain == "switch" and entity.unique_id in depricated_dict:
- if entity.disabled:
- entity_reg.async_remove(entity.entity_id)
- continue
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- "hub_switch_deprecated",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="hub_switch_deprecated",
- )
- entities.append(
- ReolinkNVRSwitchEntity(reolink_data, depricated_dict[entity.unique_id])
- )
async_add_entities(entities)
@@ -447,15 +373,15 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity):
self.async_write_ha_state()
-class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
- """Switch entity class for Reolink NVR features."""
+class ReolinkHostSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
+ """Switch entity class for Reolink host features."""
- entity_description: ReolinkNVRSwitchEntityDescription
+ entity_description: ReolinkHostSwitchEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
- entity_description: ReolinkNVRSwitchEntityDescription,
+ entity_description: ReolinkHostSwitchEntityDescription,
) -> None:
"""Initialize Reolink switch entity."""
self.entity_description = entity_description
@@ -510,3 +436,36 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
"""Turn the entity off."""
await self.entity_description.method(self._chime, False)
self.async_write_ha_state()
+
+
+class ReolinkHostChimeSwitchEntity(ReolinkHostChimeCoordinatorEntity, SwitchEntity):
+ """Base switch entity class for a chime."""
+
+ entity_description: ReolinkChimeSwitchEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeSwitchEntityDescription,
+ ) -> None:
+ """Initialize Reolink switch entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if switch is on."""
+ return self.entity_description.value(self._chime)
+
+ @raise_translated_error
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self.entity_description.method(self._chime, True)
+ self.async_write_ha_state()
+
+ @raise_translated_error
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self.entity_description.method(self._chime, False)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py
index 7f062055f7e..3a160ce3f8a 100644
--- a/homeassistant/components/reolink/views.py
+++ b/homeassistant/components/reolink/views.py
@@ -79,7 +79,7 @@ class PlaybackProxyView(HomeAssistantView):
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
try:
- mime_type, reolink_url = await host.api.get_vod_source(
+ _mime_type, reolink_url = await host.api.get_vod_source(
ch, filename_decoded, stream_res, VodRequestType(vod_type)
)
except ReolinkError as err:
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
index 0ea5fc60472..81e63371717 100644
--- a/homeassistant/components/rest_command/__init__.py
+++ b/homeassistant/components/rest_command/__init__.py
@@ -12,6 +12,7 @@ from aiohttp import hdrs
import voluptuous as vol
from homeassistant.const import (
+ CONF_AUTHENTICATION,
CONF_HEADERS,
CONF_METHOD,
CONF_PASSWORD,
@@ -20,6 +21,8 @@ from homeassistant.const import (
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
+ HTTP_BASIC_AUTHENTICATION,
+ HTTP_DIGEST_AUTHENTICATION,
SERVICE_RELOAD,
)
from homeassistant.core import (
@@ -56,6 +59,9 @@ COMMAND_SCHEMA = vol.Schema(
vol.Lower, vol.In(SUPPORT_REST_METHODS)
),
vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.template}),
+ vol.Optional(CONF_AUTHENTICATION): vol.In(
+ [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
+ ),
vol.Inclusive(CONF_USERNAME, "authentication"): cv.string,
vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string,
vol.Optional(CONF_PAYLOAD): cv.template,
@@ -109,10 +115,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
template_url = command_config[CONF_URL]
auth = None
+ digest_middleware = None
if CONF_USERNAME in command_config:
username = command_config[CONF_USERNAME]
password = command_config.get(CONF_PASSWORD, "")
- auth = aiohttp.BasicAuth(username, password=password)
+ if command_config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
+ digest_middleware = aiohttp.DigestAuthMiddleware(username, password)
+ else:
+ auth = aiohttp.BasicAuth(username, password=password)
template_payload = None
if CONF_PAYLOAD in command_config:
@@ -155,12 +165,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
try:
+ # Prepare request kwargs
+ request_kwargs = {
+ "data": payload,
+ "headers": headers or None,
+ "timeout": timeout,
+ }
+
+ # Add authentication
+ if auth is not None:
+ request_kwargs["auth"] = auth
+ elif digest_middleware is not None:
+ request_kwargs["middlewares"] = (digest_middleware,)
+
async with getattr(websession, method)(
request_url,
- data=payload,
- auth=auth,
- headers=headers or None,
- timeout=timeout,
+ **request_kwargs,
) as response:
if response.status < HTTPStatus.BAD_REQUEST:
_LOGGER.debug(
diff --git a/homeassistant/components/rhasspy/manifest.json b/homeassistant/components/rhasspy/manifest.json
index f3496f7eeab..9e6d621616b 100644
--- a/homeassistant/components/rhasspy/manifest.json
+++ b/homeassistant/components/rhasspy/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "rhasspy",
"name": "Rhasspy",
- "codeowners": ["@balloob", "@synesthesiam"],
+ "codeowners": ["@synesthesiam"],
"config_flow": true,
"dependencies": ["intent"],
"documentation": "https://www.home-assistant.io/integrations/rhasspy",
diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json
index c02cc012e0f..3989f2b56d5 100644
--- a/homeassistant/components/ridwell/manifest.json
+++ b/homeassistant/components/ridwell/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioridwell"],
- "requirements": ["aioridwell==2024.01.0"]
+ "requirements": ["aioridwell==2025.09.0"]
}
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index bc10ab7309c..39bef7b7b42 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -256,6 +256,7 @@ async def setup_device_v1(
RoborockMqttClientV1, user_data, DeviceData(device, product_info.model)
)
try:
+ await mqtt_client.async_connect()
networking = await mqtt_client.get_networking()
if networking is None:
# If the api does not return an error but does return None for
@@ -319,8 +320,11 @@ async def setup_device_a01(
product_info: HomeDataProduct,
) -> RoborockDataUpdateCoordinatorA01 | None:
"""Set up a A01 protocol device."""
- mqtt_client = RoborockMqttClientA01(
- user_data, DeviceData(device, product_info.name), product_info.category
+ mqtt_client = await hass.async_add_executor_job(
+ RoborockMqttClientA01,
+ user_data,
+ DeviceData(device, product_info.model),
+ product_info.category,
)
coord = RoborockDataUpdateCoordinatorA01(
hass, entry, device, product_info, mqtt_client
diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py
index 6a35bf79233..d1f582a94c8 100644
--- a/homeassistant/components/roborock/config_flow.py
+++ b/homeassistant/components/roborock/config_flow.py
@@ -35,6 +35,7 @@ from . import RoborockConfigEntry
from .const import (
CONF_BASE_URL,
CONF_ENTRY_CODE,
+ CONF_SHOW_BACKGROUND,
CONF_USER_DATA,
DEFAULT_DRAWABLES,
DOMAIN,
@@ -81,7 +82,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
assert self._client
errors: dict[str, str] = {}
try:
- await self._client.request_code()
+ await self._client.request_code_v4()
except RoborockAccountDoesNotExist:
errors["base"] = "invalid_email"
except RoborockUrlException:
@@ -110,7 +111,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
code = user_input[CONF_ENTRY_CODE]
_LOGGER.debug("Logging into Roborock account using email provided code")
try:
- user_data = await self._client.code_login(code)
+ user_data = await self._client.code_login_v4(code)
except RoborockInvalidCode:
errors["base"] = "invalid_code"
except RoborockException:
@@ -128,7 +129,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()}
)
self._abort_if_unique_id_configured(error="already_configured_account")
- return self._create_entry(self._client, self._username, user_data)
+ return await self._create_entry(self._client, self._username, user_data)
return self.async_show_form(
step_id="code",
@@ -175,7 +176,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_code()
return self.async_show_form(step_id="reauth_confirm", errors=errors)
- def _create_entry(
+ async def _create_entry(
self, client: RoborockApiClient, username: str, user_data: UserData
) -> ConfigFlowResult:
"""Finished config flow and create entry."""
@@ -184,7 +185,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_USERNAME: username,
CONF_USER_DATA: user_data.as_dict(),
- CONF_BASE_URL: client.base_url,
+ CONF_BASE_URL: await client.base_url,
},
)
@@ -215,6 +216,7 @@ class RoborockOptionsFlowHandler(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage the map object drawable options."""
if user_input is not None:
+ self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND)
self.options.setdefault(DRAWABLES, {}).update(user_input)
return self.async_create_entry(title="", data=self.options)
data_schema = {}
@@ -227,6 +229,12 @@ class RoborockOptionsFlowHandler(OptionsFlowWithReload):
),
)
] = bool
+ data_schema[
+ vol.Required(
+ CONF_SHOW_BACKGROUND,
+ default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, False),
+ )
+ ] = bool
return self.async_show_form(
step_id=DRAWABLES,
data_schema=vol.Schema(data_schema),
diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py
index e56fade7078..3ddce364e9f 100644
--- a/homeassistant/components/roborock/const.py
+++ b/homeassistant/components/roborock/const.py
@@ -10,6 +10,7 @@ DOMAIN = "roborock"
CONF_ENTRY_CODE = "code"
CONF_BASE_URL = "base_url"
CONF_USER_DATA = "user_data"
+CONF_SHOW_BACKGROUND = "show_background"
# Option Flow steps
DRAWABLES = "drawables"
diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py
index dc0677b25d2..e36208dfee1 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -26,7 +26,7 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
from roborock.web_api import RoborockApiClient
-from vacuum_map_parser_base.config.color import ColorsPalette
+from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.config.size import Size, Sizes
from vacuum_map_parser_base.map_data import MapData
@@ -44,6 +44,7 @@ from homeassistant.util import dt as dt_util, slugify
from .const import (
A01_UPDATE_INTERVAL,
+ CONF_SHOW_BACKGROUND,
DEFAULT_DRAWABLES,
DOMAIN,
DRAWABLES,
@@ -146,8 +147,11 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
for drawable, default_value in DEFAULT_DRAWABLES.items()
if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value)
]
+ colors = ColorsPalette()
+ if not config_entry.options.get(CONF_SHOW_BACKGROUND, False):
+ colors = ColorsPalette({SupportedColor.MAP_OUTSIDE: (0, 0, 0, 0)})
self.map_parser = RoborockMapDataParser(
- ColorsPalette(),
+ colors,
Sizes(
{
k: v * MAP_SCALE
@@ -268,6 +272,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
"""Verify that the api is reachable. If it is not, switch clients."""
if isinstance(self.api, RoborockLocalClientV1):
try:
+ await self.api.async_connect()
await self.api.ping()
except RoborockException:
_LOGGER.warning(
@@ -346,13 +351,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
def _set_current_map(self) -> None:
if (
self.roborock_device_info.props.status is not None
- and self.roborock_device_info.props.status.map_status is not None
+ and self.roborock_device_info.props.status.current_map is not None
):
- # The map status represents the map flag as flag * 4 + 3 -
- # so we have to invert that in order to get the map flag that we can use to set the current map.
- self.current_map = (
- self.roborock_device_info.props.status.map_status - 3
- ) // 4
+ self.current_map = self.roborock_device_info.props.status.current_map
async def set_current_map_rooms(self) -> None:
"""Fetch all of the rooms for the current map and set on RoborockMapInfo."""
@@ -421,7 +422,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
for map_flag in map_flags:
if map_flag != cur_map:
# Only change the map and sleep if we have multiple maps.
- await self.api.load_multi_map(map_flag)
+ await self.cloud_api.load_multi_map(map_flag)
self.current_map = map_flag
# We cannot get the map until the roborock servers fully process the
# map change.
@@ -435,11 +436,11 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# If either of these fail, we don't care, and we want to continue.
await asyncio.gather(*tasks, return_exceptions=True)
- if len(self.maps) != 1:
+ if len(self.maps) > 1:
# Set the map back to the map the user previously had selected so that it
# does not change the end user's app.
# Only needs to happen when we changed maps above.
- await self.api.load_multi_map(cur_map)
+ await self.cloud_api.load_multi_map(cur_map)
self.current_map = cur_map
diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json
index 6a96b04e12e..ae22a8b05d1 100644
--- a/homeassistant/components/roborock/icons.json
+++ b/homeassistant/components/roborock/icons.json
@@ -52,6 +52,12 @@
"total_cleaning_time": {
"default": "mdi:history"
},
+ "cleaning_brush_time_left": {
+ "default": "mdi:brush"
+ },
+ "strainer_time_left": {
+ "default": "mdi:filter-variant"
+ },
"status": {
"default": "mdi:information-outline"
},
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index 444232b5843..9339f70576b 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
- "python-roborock==2.18.2",
+ "python-roborock==2.50.2",
"vacuum-map-parser-roborock==0.1.4"
]
}
diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py
index 208020dccab..4b03e03325b 100644
--- a/homeassistant/components/roborock/select.py
+++ b/homeassistant/components/roborock/select.py
@@ -151,7 +151,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
if map_.name == option:
await self._send_command(
RoborockCommand.LOAD_MULTI_MAP,
- self.api,
+ self.cloud_api,
[map_id],
)
# Update the current map id manually so that nothing gets broken
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index a007d6fa457..1e716b193c1 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -101,6 +101,24 @@ SENSOR_DESCRIPTIONS = [
entity_category=EntityCategory.DIAGNOSTIC,
protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME,
),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ key="cleaning_brush_time_left",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="cleaning_brush_time_left",
+ value_fn=lambda data: data.consumable.cleaning_brush_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_dock_entity=True,
+ ),
+ RoborockSensorDescription(
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ key="strainer_time_left",
+ device_class=SensorDeviceClass.DURATION,
+ translation_key="strainer_time_left",
+ value_fn=lambda data: data.consumable.strainer_time_left,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_dock_entity=True,
+ ),
RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.SECONDS,
key="sensor_time_left",
diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json
index 2d1fcebd9d3..a8f58cf2492 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -60,7 +60,8 @@
"room_names": "Room names",
"vacuum_position": "Vacuum position",
"virtual_walls": "Virtual walls",
- "zones": "Zones"
+ "zones": "Zones",
+ "show_background": "Show background"
},
"data_description": {
"charger": "Show the charger on the map.",
@@ -79,7 +80,8 @@
"room_names": "Show room names on the map.",
"vacuum_position": "Show the vacuum position on the map.",
"virtual_walls": "Show virtual walls on the map.",
- "zones": "Show zones on the map."
+ "zones": "Show zones on the map.",
+ "show_background": "Add a background to the map."
}
}
}
@@ -218,6 +220,12 @@
"sensor_time_left": {
"name": "Sensor time left"
},
+ "cleaning_brush_time_left": {
+ "name": "Maintenance brush time left"
+ },
+ "strainer_time_left": {
+ "name": "Strainer time left"
+ },
"status": {
"name": "Status",
"state": {
@@ -375,8 +383,10 @@
"max": "Max",
"high": "[%key:common::state::high%]",
"intense": "Intense",
+ "extreme": "Extreme",
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
"custom_water_flow": "Custom water flow",
+ "vac_followed_by_mop": "Vacuum followed by mop",
"smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]"
}
},
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index afdb3b19cb4..0de5678ea88 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -5,10 +5,6 @@ from typing import Any
from roborock.code_mappings import RoborockStateCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
-from vacuum_map_parser_base.config.color import ColorsPalette
-from vacuum_map_parser_base.config.image_config import ImageConfig
-from vacuum_map_parser_base.config.size import Sizes
-from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
import voluptuous as vol
from homeassistant.components.vacuum import (
@@ -223,8 +219,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
translation_domain=DOMAIN,
translation_key="map_failure",
)
- parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), [])
- parsed_map = parser.parse(map_data)
+ parsed_map = self.coordinator.map_parser.parse(map_data)
robot_position = parsed_map.vacuum_position
if robot_position is None:
diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py
index 09affe4369b..5387963727d 100644
--- a/homeassistant/components/roku/browse_media.py
+++ b/homeassistant/components/roku/browse_media.py
@@ -142,7 +142,7 @@ async def root_payload(
children.extend(browse_item.children)
else:
children.append(browse_item)
- except media_source.BrowseError:
+ except BrowseError:
pass
if len(children) == 1:
diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py
index eb1b3696102..71ebab3ae43 100644
--- a/homeassistant/components/roomba/entity.py
+++ b/homeassistant/components/roomba/entity.py
@@ -66,6 +66,16 @@ class IRobotEntity(Entity):
"""Return the battery stats."""
return self.vacuum_state.get("bbchg3", {})
+ @property
+ def tank_level(self) -> int | None:
+ """Return the tank level."""
+ return self.vacuum_state.get("tankLvl")
+
+ @property
+ def dock_tank_level(self) -> int | None:
+ """Return the dock tank level."""
+ return self.vacuum_state.get("dock", {}).get("tankLvl")
+
@property
def last_mission(self):
"""Return last mission start time."""
diff --git a/homeassistant/components/roomba/icons.json b/homeassistant/components/roomba/icons.json
index 8466ecb51e3..9cf2fdc9836 100644
--- a/homeassistant/components/roomba/icons.json
+++ b/homeassistant/components/roomba/icons.json
@@ -35,6 +35,12 @@
},
"last_mission": {
"default": "mdi:calendar-clock"
+ },
+ "tank_level": {
+ "default": "mdi:water"
+ },
+ "dock_tank_level": {
+ "default": "mdi:water"
}
}
}
diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py
index ae82424ec34..803319e0e84 100644
--- a/homeassistant/components/roomba/sensor.py
+++ b/homeassistant/components/roomba/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
-from .entity import IRobotEntity
+from .entity import IRobotEntity, roomba_reported_state
from .models import RoombaData
@@ -29,6 +29,16 @@ class RoombaSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[IRobotEntity], StateType]
+DOCK_SENSORS: list[RoombaSensorEntityDescription] = [
+ RoombaSensorEntityDescription(
+ key="dock_tank_level",
+ translation_key="dock_tank_level",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda self: self.dock_tank_level,
+ ),
+]
+
SENSORS: list[RoombaSensorEntityDescription] = [
RoombaSensorEntityDescription(
key="battery",
@@ -37,6 +47,13 @@ SENSORS: list[RoombaSensorEntityDescription] = [
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: self.vacuum_state.get("batPct"),
),
+ RoombaSensorEntityDescription(
+ key="tank_level",
+ translation_key="tank_level",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda self: self.tank_level,
+ ),
RoombaSensorEntityDescription(
key="battery_cycles",
translation_key="battery_cycles",
@@ -132,8 +149,16 @@ async def async_setup_entry(
roomba = domain_data.roomba
blid = domain_data.blid
+ sensor_list: list[RoombaSensorEntityDescription] = SENSORS
+
+ has_dock: bool = len(roomba_reported_state(roomba).get("dock", {})) > 0
+
+ if has_dock:
+ sensor_list.extend(DOCK_SENSORS)
+
async_add_entities(
- RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS
+ RoombaSensor(roomba, blid, entity_description)
+ for entity_description in sensor_list
)
diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json
index 0db70a6a141..938c941f238 100644
--- a/homeassistant/components/roomba/strings.json
+++ b/homeassistant/components/roomba/strings.json
@@ -23,11 +23,11 @@
}
},
"link": {
- "title": "Retrieve Password",
+ "title": "Retrieve password",
"description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button (or both Home and Spot buttons) on {name} until the device generates a sound (about two seconds), then submit within 30 seconds."
},
"link_manual": {
- "title": "Enter Password",
+ "title": "Enter password",
"description": "The password could not be retrieved from the device automatically. Please make sure that the iRobot app is not open on any device while trying to retrieve the password. Please follow the steps outlined in the documentation at: {auth_help_url}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
@@ -90,6 +90,12 @@
},
"last_mission": {
"name": "Last mission start time"
+ },
+ "tank_level": {
+ "name": "Tank level"
+ },
+ "dock_tank_level": {
+ "name": "Dock tank level"
}
}
}
diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py
index 0c24301f2af..d955c7a7ecf 100644
--- a/homeassistant/components/roomba/vacuum.py
+++ b/homeassistant/components/roomba/vacuum.py
@@ -403,11 +403,16 @@ class BraavaJet(IRobotVacuum):
detected_pad = state.get("detectedPad")
mop_ready = state.get("mopReady", {})
lid_closed = mop_ready.get("lidClosed")
- tank_present = mop_ready.get("tankPresent")
+ tank_present = mop_ready.get("tankPresent") or state.get("tankPresent")
tank_level = state.get("tankLvl")
state_attrs[ATTR_DETECTED_PAD] = detected_pad
state_attrs[ATTR_LID_CLOSED] = lid_closed
state_attrs[ATTR_TANK_PRESENT] = tank_present
state_attrs[ATTR_TANK_LEVEL] = tank_level
+ bin_raw_state = state.get("bin", {})
+ if bin_raw_state.get("present") is not None:
+ state_attrs[ATTR_BIN_PRESENT] = bin_raw_state.get("present")
+ if bin_raw_state.get("full") is not None:
+ state_attrs[ATTR_BIN_FULL] = bin_raw_state.get("full")
return state_attrs
diff --git a/homeassistant/components/route_b_smart_meter/__init__.py b/homeassistant/components/route_b_smart_meter/__init__.py
new file mode 100644
index 00000000000..5e8a941c73e
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/__init__.py
@@ -0,0 +1,28 @@
+"""The Smart Meter B Route integration."""
+
+import logging
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import BRouteConfigEntry, BRouteUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool:
+ """Set up Smart Meter B Route from a config entry."""
+
+ coordinator = BRouteUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool:
+ """Unload a config entry."""
+ await hass.async_add_executor_job(entry.runtime_data.api.close)
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py
new file mode 100644
index 00000000000..1cbeeab4c4e
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/config_flow.py
@@ -0,0 +1,116 @@
+"""Config flow for Smart Meter B Route integration."""
+
+import logging
+from typing import Any
+
+from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure
+from serial.tools.list_ports import comports
+from serial.tools.list_ports_common import ListPortInfo
+import voluptuous as vol
+
+from homeassistant.components.usb import get_serial_by_id, human_readable_device_name
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD
+from homeassistant.core import callback
+from homeassistant.helpers.service_info.usb import UsbServiceInfo
+
+from .const import DOMAIN, ENTRY_TITLE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _validate_input(device: str, id: str, password: str) -> None:
+ """Validate the user input allows us to connect."""
+ with Momonga(dev=device, rbid=id, pwd=password):
+ pass
+
+
+def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str:
+ return human_readable_device_name(
+ port.device,
+ port.serial_number,
+ port.manufacturer,
+ port.description,
+ str(port.vid) if port.vid else None,
+ str(port.pid) if port.pid else None,
+ )
+
+
+class BRouteConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Smart Meter B Route."""
+
+ VERSION = 1
+
+ device: UsbServiceInfo | None = None
+
+ @callback
+ def _get_discovered_device_id_and_name(
+ self, device_options: dict[str, ListPortInfo]
+ ) -> tuple[str | None, str | None]:
+ discovered_device_id = (
+ get_serial_by_id(self.device.device) if self.device else None
+ )
+ discovered_device = (
+ device_options.get(discovered_device_id) if discovered_device_id else None
+ )
+ discovered_device_name = (
+ _human_readable_device_name(discovered_device)
+ if discovered_device
+ else None
+ )
+ return discovered_device_id, discovered_device_name
+
+ async def _get_usb_devices(self) -> dict[str, ListPortInfo]:
+ """Return a list of available USB devices."""
+ devices = await self.hass.async_add_executor_job(comports)
+ return {get_serial_by_id(port.device): port for port in devices}
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ device_options = await self._get_usb_devices()
+ if user_input is not None:
+ try:
+ await self.hass.async_add_executor_job(
+ _validate_input,
+ user_input[CONF_DEVICE],
+ user_input[CONF_ID],
+ user_input[CONF_PASSWORD],
+ )
+ except MomongaSkScanFailure:
+ errors["base"] = "cannot_connect"
+ except MomongaSkJoinFailure:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(
+ user_input[CONF_ID], raise_on_progress=False
+ )
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=ENTRY_TITLE, data=user_input)
+
+ discovered_device_id, discovered_device_name = (
+ self._get_discovered_device_id_and_name(device_options)
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_DEVICE, default=discovered_device_id): vol.In(
+ {discovered_device_id: discovered_device_name}
+ if discovered_device_id and discovered_device_name
+ else {
+ name: _human_readable_device_name(device)
+ for name, device in device_options.items()
+ }
+ ),
+ vol.Required(CONF_ID): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/route_b_smart_meter/const.py b/homeassistant/components/route_b_smart_meter/const.py
new file mode 100644
index 00000000000..ecd3fc48bfc
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/const.py
@@ -0,0 +1,12 @@
+"""Constants for the Smart Meter B Route integration."""
+
+from datetime import timedelta
+
+DOMAIN = "route_b_smart_meter"
+ENTRY_TITLE = "Route B Smart Meter"
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=300)
+
+ATTR_API_INSTANTANEOUS_POWER = "instantaneous_power"
+ATTR_API_TOTAL_CONSUMPTION = "total_consumption"
+ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE = "instantaneous_current_t_phase"
+ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE = "instantaneous_current_r_phase"
diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py
new file mode 100644
index 00000000000..7cfa2810b5b
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/coordinator.py
@@ -0,0 +1,75 @@
+"""DataUpdateCoordinator for the Smart Meter B-route integration."""
+
+from dataclasses import dataclass
+import logging
+
+from momonga import Momonga, MomongaError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class BRouteData:
+ """Class for data of the B Route."""
+
+ instantaneous_current_r_phase: float
+ instantaneous_current_t_phase: float
+ instantaneous_power: float
+ total_consumption: float
+
+
+type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator]
+
+
+class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]):
+ """The B Route update coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: BRouteConfigEntry,
+ ) -> None:
+ """Initialize."""
+
+ self.device = entry.data[CONF_DEVICE]
+ self.bid = entry.data[CONF_ID]
+ password = entry.data[CONF_PASSWORD]
+
+ self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password)
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ config_entry=entry,
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
+
+ async def _async_setup(self) -> None:
+ await self.hass.async_add_executor_job(
+ self.api.open,
+ )
+
+ def _get_data(self) -> BRouteData:
+ """Get the data from API."""
+ current = self.api.get_instantaneous_current()
+ return BRouteData(
+ instantaneous_current_r_phase=current["r phase current"],
+ instantaneous_current_t_phase=current["t phase current"],
+ instantaneous_power=self.api.get_instantaneous_power(),
+ total_consumption=self.api.get_measured_cumulative_energy(),
+ )
+
+ async def _async_update_data(self) -> BRouteData:
+ """Update data."""
+ try:
+ return await self.hass.async_add_executor_job(self._get_data)
+ except MomongaError as error:
+ raise UpdateFailed(error) from error
diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json
new file mode 100644
index 00000000000..d1189d0a542
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/manifest.json
@@ -0,0 +1,17 @@
+{
+ "domain": "route_b_smart_meter",
+ "name": "Smart Meter B Route",
+ "codeowners": ["@SeraphicRav"],
+ "config_flow": true,
+ "dependencies": ["usb"],
+ "documentation": "https://www.home-assistant.io/integrations/route_b_smart_meter",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "loggers": [
+ "momonga.momonga",
+ "momonga.momonga_session_manager",
+ "momonga.sk_wrapper_logger"
+ ],
+ "quality_scale": "bronze",
+ "requirements": ["pyserial==3.5", "momonga==0.1.5"]
+}
diff --git a/homeassistant/components/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml
new file mode 100644
index 00000000000..f6123b6e4c9
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling:
+ status: done
+ brands:
+ status: exempt
+ comment: |
+ The integration is not specific to a single brand, it does not have a logo.
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ The integration does not use events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery:
+ status: exempt
+ comment: |
+ The manufacturer does not use unique identifiers for devices.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession:
+ status: exempt
+ comment: |
+ The integration does not use HTTP.
+ strict-typing: todo
diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py
new file mode 100644
index 00000000000..c8034528f5a
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/sensor.py
@@ -0,0 +1,109 @@
+"""Smart Meter B Route."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import BRouteConfigEntry
+from .const import (
+ ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
+ ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
+ ATTR_API_INSTANTANEOUS_POWER,
+ ATTR_API_TOTAL_CONSUMPTION,
+ DOMAIN,
+)
+from .coordinator import BRouteData, BRouteUpdateCoordinator
+
+
+@dataclass(frozen=True, kw_only=True)
+class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription):
+ """Sensor entity description with data accessor."""
+
+ value_accessor: Callable[[BRouteData], StateType]
+
+
+SENSOR_DESCRIPTIONS = (
+ SensorEntityDescriptionWithValueAccessor(
+ key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
+ translation_key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_accessor=lambda data: data.instantaneous_current_r_phase,
+ ),
+ SensorEntityDescriptionWithValueAccessor(
+ key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
+ translation_key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ value_accessor=lambda data: data.instantaneous_current_t_phase,
+ ),
+ SensorEntityDescriptionWithValueAccessor(
+ key=ATTR_API_INSTANTANEOUS_POWER,
+ translation_key=ATTR_API_INSTANTANEOUS_POWER,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_accessor=lambda data: data.instantaneous_power,
+ ),
+ SensorEntityDescriptionWithValueAccessor(
+ key=ATTR_API_TOTAL_CONSUMPTION,
+ translation_key=ATTR_API_TOTAL_CONSUMPTION,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_accessor=lambda data: data.total_consumption,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BRouteConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Smart Meter B-route entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ SmartMeterBRouteSensor(coordinator, description)
+ for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class SmartMeterBRouteSensor(CoordinatorEntity[BRouteUpdateCoordinator], SensorEntity):
+ """Representation of a Smart Meter B-route sensor entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: BRouteUpdateCoordinator,
+ description: SensorEntityDescriptionWithValueAccessor,
+ ) -> None:
+ """Initialize Smart Meter B-route sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description: SensorEntityDescriptionWithValueAccessor = description
+ self._attr_unique_id = f"{coordinator.bid}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.bid)},
+ name=f"Route B Smart Meter {coordinator.bid}",
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_accessor(self.coordinator.data)
diff --git a/homeassistant/components/route_b_smart_meter/strings.json b/homeassistant/components/route_b_smart_meter/strings.json
new file mode 100644
index 00000000000..382ff6edaa0
--- /dev/null
+++ b/homeassistant/components/route_b_smart_meter/strings.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data_description": {
+ "device": "[%key:common::config_flow::data::device%]",
+ "id": "B Route ID",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data": {
+ "device": "[%key:common::config_flow::data::device%]",
+ "id": "B Route ID",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "instantaneous_power": {
+ "name": "Instantaneous power"
+ },
+ "total_consumption": {
+ "name": "Total consumption"
+ },
+ "instantaneous_current_t_phase": {
+ "name": "Instantaneous current T phase"
+ },
+ "instantaneous_current_r_phase": {
+ "name": "Instantaneous current R phase"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index efaf8f195ad..b1b35385495 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -7,6 +7,6 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
- "requirements": ["aiorussound==4.8.1"],
+ "requirements": ["aiorussound==4.8.2"],
"zeroconf": ["_rio._tcp.local."]
}
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index 1b927757a39..e9ce8db0b95 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -34,7 +34,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["samsungctl", "samsungtvws"],
- "quality_scale": "bronze",
+ "quality_scale": "silver",
"requirements": [
"getmac==0.9.5",
"samsungctl[websocket]==0.7.1",
diff --git a/homeassistant/components/samsungtv/quality_scale.yaml b/homeassistant/components/samsungtv/quality_scale.yaml
index 845ebfe6e46..4cea6ea319e 100644
--- a/homeassistant/components/samsungtv/quality_scale.yaml
+++ b/homeassistant/components/samsungtv/quality_scale.yaml
@@ -32,9 +32,7 @@ rules:
status: exempt
comment: no configuration options so far
docs-installation-parameters: done
- entity-unavailable:
- status: todo
- comment: check super().unavailable
+ entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py
index 466faf27b12..2ffcd243d39 100644
--- a/homeassistant/components/satel_integra/__init__.py
+++ b/homeassistant/components/satel_integra/__init__.py
@@ -1,59 +1,67 @@
"""Support for Satel Integra devices."""
-import collections
import logging
from satel_integra.satel_integra import AsyncSatel
import voluptuous as vol
-from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ CONF_CODE,
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
-DEFAULT_ALARM_NAME = "satel_integra"
-DEFAULT_PORT = 7094
-DEFAULT_CONF_ARM_HOME_MODE = 1
-DEFAULT_DEVICE_PARTITION = 1
-DEFAULT_ZONE_TYPE = "motion"
+from .const import (
+ CONF_ARM_HOME_MODE,
+ CONF_DEVICE_PARTITIONS,
+ CONF_OUTPUT_NUMBER,
+ CONF_OUTPUTS,
+ CONF_PARTITION_NUMBER,
+ CONF_SWITCHABLE_OUTPUT_NUMBER,
+ CONF_SWITCHABLE_OUTPUTS,
+ CONF_ZONE_NUMBER,
+ CONF_ZONE_TYPE,
+ CONF_ZONES,
+ DEFAULT_CONF_ARM_HOME_MODE,
+ DEFAULT_PORT,
+ DEFAULT_ZONE_TYPE,
+ DOMAIN,
+ SIGNAL_OUTPUTS_UPDATED,
+ SIGNAL_PANEL_MESSAGE,
+ SIGNAL_ZONES_UPDATED,
+ SUBENTRY_TYPE_OUTPUT,
+ SUBENTRY_TYPE_PARTITION,
+ SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
+ SUBENTRY_TYPE_ZONE,
+ ZONES,
+ SatelConfigEntry,
+)
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "satel_integra"
+PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH]
-DATA_SATEL = "satel_integra"
-
-CONF_DEVICE_CODE = "code"
-CONF_DEVICE_PARTITIONS = "partitions"
-CONF_ARM_HOME_MODE = "arm_home_mode"
-CONF_ZONE_NAME = "name"
-CONF_ZONE_TYPE = "type"
-CONF_ZONES = "zones"
-CONF_OUTPUTS = "outputs"
-CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
-
-ZONES = "zones"
-
-SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message"
-SIGNAL_PANEL_ARM_AWAY = "satel_integra.panel_arm_away"
-SIGNAL_PANEL_ARM_HOME = "satel_integra.panel_arm_home"
-SIGNAL_PANEL_DISARM = "satel_integra.panel_disarm"
-
-SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated"
-SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated"
ZONE_SCHEMA = vol.Schema(
{
- vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string,
}
)
-EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_NAME): cv.string})
+EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
PARTITION_SCHEMA = vol.Schema(
{
- vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
[1, 2, 3]
),
@@ -63,7 +71,7 @@ PARTITION_SCHEMA = vol.Schema(
def is_alarm_code_necessary(value):
"""Check if alarm code must be configured."""
- if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_DEVICE_CODE not in value:
+ if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_CODE not in value:
raise vol.Invalid("You need to specify alarm code to use switchable_outputs")
return value
@@ -75,7 +83,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_DEVICE_CODE): cv.string,
+ vol.Optional(CONF_CODE): cv.string,
vol.Optional(CONF_DEVICE_PARTITIONS, default={}): {
vol.Coerce(int): PARTITION_SCHEMA
},
@@ -92,64 +100,107 @@ CONFIG_SCHEMA = vol.Schema(
)
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Satel Integra component."""
- conf = config[DOMAIN]
+async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
+ """Set up Satel Integra from YAML."""
- zones = conf.get(CONF_ZONES)
- outputs = conf.get(CONF_OUTPUTS)
- switchable_outputs = conf.get(CONF_SWITCHABLE_OUTPUTS)
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
- partitions = conf.get(CONF_DEVICE_PARTITIONS)
+ if config := hass_config.get(DOMAIN):
+ hass.async_create_task(_async_import(hass, config))
- monitored_outputs = collections.OrderedDict(
- list(outputs.items()) + list(switchable_outputs.items())
+ return True
+
+
+async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
+ """Process YAML import."""
+
+ if not hass.config_entries.async_entries(DOMAIN):
+ # Start import flow
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+
+ if result.get("type") == FlowResultType.ABORT:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_yaml_import_issue_cannot_connect",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml_import_issue_cannot_connect",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Satel Integra",
+ },
+ )
+ return
+
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2026.4.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Satel Integra",
+ },
)
- controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions)
- hass.data[DATA_SATEL] = controller
+async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:
+ """Set up Satel Integra from a config entry."""
+
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+
+ # Make sure we initialize the Satel controller with the configured entries to monitor
+ partitions = [
+ subentry.data[CONF_PARTITION_NUMBER]
+ for subentry in entry.subentries.values()
+ if subentry.subentry_type == SUBENTRY_TYPE_PARTITION
+ ]
+
+ zones = [
+ subentry.data[CONF_ZONE_NUMBER]
+ for subentry in entry.subentries.values()
+ if subentry.subentry_type == SUBENTRY_TYPE_ZONE
+ ]
+
+ outputs = [
+ subentry.data[CONF_OUTPUT_NUMBER]
+ for subentry in entry.subentries.values()
+ if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT
+ ]
+
+ switchable_outputs = [
+ subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
+ for subentry in entry.subentries.values()
+ if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT
+ ]
+
+ monitored_outputs = outputs + switchable_outputs
+
+ controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions)
result = await controller.connect()
if not result:
- return False
+ raise ConfigEntryNotReady("Controller failed to connect")
+
+ entry.runtime_data = controller
@callback
def _close(*_):
controller.close()
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
+ entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
- _LOGGER.debug("Arm home config: %s, mode: %s ", conf, conf.get(CONF_ARM_HOME_MODE))
-
- hass.async_create_task(
- async_load_platform(hass, Platform.ALARM_CONTROL_PANEL, DOMAIN, conf, config)
- )
-
- hass.async_create_task(
- async_load_platform(
- hass,
- Platform.BINARY_SENSOR,
- DOMAIN,
- {CONF_ZONES: zones, CONF_OUTPUTS: outputs},
- config,
- )
- )
-
- hass.async_create_task(
- async_load_platform(
- hass,
- Platform.SWITCH,
- DOMAIN,
- {
- CONF_SWITCHABLE_OUTPUTS: switchable_outputs,
- CONF_DEVICE_CODE: conf.get(CONF_DEVICE_CODE),
- },
- config,
- )
- )
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def alarm_status_update_callback():
@@ -179,3 +230,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:
+ """Unloading the Satel platforms."""
+
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ controller = entry.runtime_data
+ controller.close()
+
+ return unload_ok
+
+
+async def update_listener(hass: HomeAssistant, entry: SatelConfigEntry) -> None:
+ """Handle options update."""
+ hass.config_entries.async_schedule_reload(entry.entry_id)
diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py
index 41b2d0d561b..b00741e1971 100644
--- a/homeassistant/components/satel_integra/alarm_control_panel.py
+++ b/homeassistant/components/satel_integra/alarm_control_panel.py
@@ -14,46 +14,49 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
+from homeassistant.const import CONF_NAME
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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import (
+from .const import (
CONF_ARM_HOME_MODE,
- CONF_DEVICE_PARTITIONS,
- CONF_ZONE_NAME,
- DATA_SATEL,
+ CONF_PARTITION_NUMBER,
SIGNAL_PANEL_MESSAGE,
+ SUBENTRY_TYPE_PARTITION,
+ SatelConfigEntry,
)
_LOGGER = logging.getLogger(__name__)
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ config_entry: SatelConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up for Satel Integra alarm panels."""
- if not discovery_info:
- return
- configured_partitions = discovery_info[CONF_DEVICE_PARTITIONS]
- controller = hass.data[DATA_SATEL]
+ controller = config_entry.runtime_data
- devices = []
+ partition_subentries = filter(
+ lambda entry: entry.subentry_type == SUBENTRY_TYPE_PARTITION,
+ config_entry.subentries.values(),
+ )
- for partition_num, device_config_data in configured_partitions.items():
- zone_name = device_config_data[CONF_ZONE_NAME]
- arm_home_mode = device_config_data.get(CONF_ARM_HOME_MODE)
- device = SatelIntegraAlarmPanel(
- controller, zone_name, arm_home_mode, partition_num
+ for subentry in partition_subentries:
+ partition_num = subentry.data[CONF_PARTITION_NUMBER]
+ zone_name = subentry.data[CONF_NAME]
+ arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
+
+ async_add_entities(
+ [
+ SatelIntegraAlarmPanel(
+ controller, zone_name, arm_home_mode, partition_num
+ )
+ ],
+ config_subentry_id=subentry.subentry_id,
)
- devices.append(device)
-
- async_add_entities(devices)
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
@@ -66,7 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
- def __init__(self, controller, name, arm_home_mode, partition_id):
+ def __init__(self, controller, name, arm_home_mode, partition_id) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py
index 8ff54940635..fdeef7cffc4 100644
--- a/homeassistant/components/satel_integra/binary_sensor.py
+++ b/homeassistant/components/satel_integra/binary_sensor.py
@@ -6,61 +6,81 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
+from homeassistant.const import CONF_NAME
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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import (
+from .const import (
+ CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
- CONF_ZONE_NAME,
+ CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
- DATA_SATEL,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
+ SUBENTRY_TYPE_OUTPUT,
+ SUBENTRY_TYPE_ZONE,
+ SatelConfigEntry,
)
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ config_entry: SatelConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Satel Integra binary sensor devices."""
- if not discovery_info:
- return
- configured_zones = discovery_info[CONF_ZONES]
- controller = hass.data[DATA_SATEL]
+ controller = config_entry.runtime_data
- devices = []
+ zone_subentries = filter(
+ lambda entry: entry.subentry_type == SUBENTRY_TYPE_ZONE,
+ config_entry.subentries.values(),
+ )
- for zone_num, device_config_data in configured_zones.items():
- zone_type = device_config_data[CONF_ZONE_TYPE]
- zone_name = device_config_data[CONF_ZONE_NAME]
- device = SatelIntegraBinarySensor(
- controller, zone_num, zone_name, zone_type, CONF_ZONES, SIGNAL_ZONES_UPDATED
+ for subentry in zone_subentries:
+ zone_num = subentry.data[CONF_ZONE_NUMBER]
+ zone_type = subentry.data[CONF_ZONE_TYPE]
+ zone_name = subentry.data[CONF_NAME]
+
+ async_add_entities(
+ [
+ SatelIntegraBinarySensor(
+ controller,
+ zone_num,
+ zone_name,
+ zone_type,
+ CONF_ZONES,
+ SIGNAL_ZONES_UPDATED,
+ )
+ ],
+ config_subentry_id=subentry.subentry_id,
)
- devices.append(device)
- configured_outputs = discovery_info[CONF_OUTPUTS]
+ output_subentries = filter(
+ lambda entry: entry.subentry_type == SUBENTRY_TYPE_OUTPUT,
+ config_entry.subentries.values(),
+ )
- for zone_num, device_config_data in configured_outputs.items():
- zone_type = device_config_data[CONF_ZONE_TYPE]
- zone_name = device_config_data[CONF_ZONE_NAME]
- device = SatelIntegraBinarySensor(
- controller,
- zone_num,
- zone_name,
- zone_type,
- CONF_OUTPUTS,
- SIGNAL_OUTPUTS_UPDATED,
+ for subentry in output_subentries:
+ output_num = subentry.data[CONF_OUTPUT_NUMBER]
+ ouput_type = subentry.data[CONF_ZONE_TYPE]
+ output_name = subentry.data[CONF_NAME]
+
+ async_add_entities(
+ [
+ SatelIntegraBinarySensor(
+ controller,
+ output_num,
+ output_name,
+ ouput_type,
+ CONF_OUTPUTS,
+ SIGNAL_OUTPUTS_UPDATED,
+ )
+ ],
+ config_subentry_id=subentry.subentry_id,
)
- devices.append(device)
-
- async_add_entities(devices)
class SatelIntegraBinarySensor(BinarySensorEntity):
diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py
new file mode 100644
index 00000000000..d5427488fc7
--- /dev/null
+++ b/homeassistant/components/satel_integra/config_flow.py
@@ -0,0 +1,496 @@
+"""Config flow for Satel Integra."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from satel_integra.satel_integra import AsyncSatel
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryData,
+ ConfigSubentryFlow,
+ OptionsFlowWithReload,
+ SubentryFlowResult,
+)
+from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv, selector
+
+from .const import (
+ CONF_ARM_HOME_MODE,
+ CONF_DEVICE_PARTITIONS,
+ CONF_OUTPUT_NUMBER,
+ CONF_OUTPUTS,
+ CONF_PARTITION_NUMBER,
+ CONF_SWITCHABLE_OUTPUT_NUMBER,
+ CONF_SWITCHABLE_OUTPUTS,
+ CONF_ZONE_NUMBER,
+ CONF_ZONE_TYPE,
+ CONF_ZONES,
+ DEFAULT_CONF_ARM_HOME_MODE,
+ DEFAULT_PORT,
+ DOMAIN,
+ SUBENTRY_TYPE_OUTPUT,
+ SUBENTRY_TYPE_PARTITION,
+ SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
+ SUBENTRY_TYPE_ZONE,
+ SatelConfigEntry,
+)
+
+_LOGGER = logging.getLogger(__package__)
+
+CONNECTION_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_CODE): cv.string,
+ }
+)
+
+CODE_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_CODE): cv.string,
+ }
+)
+
+PARTITION_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
+ [1, 2, 3]
+ ),
+ }
+)
+
+ZONE_AND_OUTPUT_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(
+ CONF_ZONE_TYPE, default=BinarySensorDeviceClass.MOTION
+ ): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[cls.value for cls in BinarySensorDeviceClass],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="binary_sensor_device_class",
+ sort=True,
+ ),
+ ),
+ }
+)
+
+SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
+
+
+class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a Satel Integra config flow."""
+
+ VERSION = 1
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: SatelConfigEntry,
+ ) -> SatelOptionsFlow:
+ """Create the options flow."""
+ return SatelOptionsFlow()
+
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {
+ SUBENTRY_TYPE_PARTITION: PartitionSubentryFlowHandler,
+ SUBENTRY_TYPE_ZONE: ZoneSubentryFlowHandler,
+ SUBENTRY_TYPE_OUTPUT: OutputSubentryFlowHandler,
+ SUBENTRY_TYPE_SWITCHABLE_OUTPUT: SwitchableOutputSubentryFlowHandler,
+ }
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ valid = await self.test_connection(
+ user_input[CONF_HOST], user_input[CONF_PORT]
+ )
+
+ if valid:
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data={
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_PORT: user_input[CONF_PORT],
+ },
+ options={CONF_CODE: user_input.get(CONF_CODE)},
+ )
+
+ errors["base"] = "cannot_connect"
+
+ return self.async_show_form(
+ step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(
+ self, import_config: dict[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by import."""
+
+ valid = await self.test_connection(
+ import_config[CONF_HOST], import_config.get(CONF_PORT, DEFAULT_PORT)
+ )
+
+ if valid:
+ subentries: list[ConfigSubentryData] = []
+
+ for partition_number, partition_data in import_config.get(
+ CONF_DEVICE_PARTITIONS, {}
+ ).items():
+ subentries.append(
+ {
+ "subentry_type": SUBENTRY_TYPE_PARTITION,
+ "title": partition_data[CONF_NAME],
+ "unique_id": f"{SUBENTRY_TYPE_PARTITION}_{partition_number}",
+ "data": {
+ CONF_NAME: partition_data[CONF_NAME],
+ CONF_ARM_HOME_MODE: partition_data.get(
+ CONF_ARM_HOME_MODE, DEFAULT_CONF_ARM_HOME_MODE
+ ),
+ CONF_PARTITION_NUMBER: partition_number,
+ },
+ }
+ )
+
+ for zone_number, zone_data in import_config.get(CONF_ZONES, {}).items():
+ subentries.append(
+ {
+ "subentry_type": SUBENTRY_TYPE_ZONE,
+ "title": zone_data[CONF_NAME],
+ "unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_number}",
+ "data": {
+ CONF_NAME: zone_data[CONF_NAME],
+ CONF_ZONE_NUMBER: zone_number,
+ CONF_ZONE_TYPE: zone_data.get(
+ CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
+ ),
+ },
+ }
+ )
+
+ for output_number, output_data in import_config.get(
+ CONF_OUTPUTS, {}
+ ).items():
+ subentries.append(
+ {
+ "subentry_type": SUBENTRY_TYPE_OUTPUT,
+ "title": output_data[CONF_NAME],
+ "unique_id": f"{SUBENTRY_TYPE_OUTPUT}_{output_number}",
+ "data": {
+ CONF_NAME: output_data[CONF_NAME],
+ CONF_OUTPUT_NUMBER: output_number,
+ CONF_ZONE_TYPE: output_data.get(
+ CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
+ ),
+ },
+ }
+ )
+
+ for switchable_output_number, switchable_output_data in import_config.get(
+ CONF_SWITCHABLE_OUTPUTS, {}
+ ).items():
+ subentries.append(
+ {
+ "subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
+ "title": switchable_output_data[CONF_NAME],
+ "unique_id": f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{switchable_output_number}",
+ "data": {
+ CONF_NAME: switchable_output_data[CONF_NAME],
+ CONF_SWITCHABLE_OUTPUT_NUMBER: switchable_output_number,
+ },
+ }
+ )
+
+ return self.async_create_entry(
+ title=import_config[CONF_HOST],
+ data={
+ CONF_HOST: import_config[CONF_HOST],
+ CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT),
+ },
+ options={CONF_CODE: import_config.get(CONF_CODE)},
+ subentries=subentries,
+ )
+
+ return self.async_abort(reason="cannot_connect")
+
+ async def test_connection(self, host: str, port: int) -> bool:
+ """Test a connection to the Satel alarm."""
+ controller = AsyncSatel(host, port, self.hass.loop)
+
+ result = await controller.connect()
+
+ # Make sure we close the connection again
+ controller.close()
+
+ return result
+
+
+class SatelOptionsFlow(OptionsFlowWithReload):
+ """Handle Satel options flow."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Init step."""
+ if user_input is not None:
+ return self.async_create_entry(data={CONF_CODE: user_input.get(CONF_CODE)})
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ CODE_SCHEMA, self.config_entry.options
+ ),
+ )
+
+
+class PartitionSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying a partition."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add new partition."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ unique_id = f"{SUBENTRY_TYPE_PARTITION}_{user_input[CONF_PARTITION_NUMBER]}"
+
+ for existing_subentry in self._get_entry().subentries.values():
+ if existing_subentry.unique_id == unique_id:
+ errors[CONF_PARTITION_NUMBER] = "already_configured"
+
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_PARTITION_NUMBER): vol.All(
+ vol.Coerce(int), vol.Range(min=1)
+ ),
+ }
+ ).extend(PARTITION_SCHEMA.schema),
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure existing partition."""
+ subconfig_entry = self._get_reconfigure_subentry()
+
+ if user_input is not None:
+ return self.async_update_and_abort(
+ self._get_entry(),
+ subconfig_entry,
+ title=user_input[CONF_NAME],
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ PARTITION_SCHEMA,
+ subconfig_entry.data,
+ ),
+ description_placeholders={
+ CONF_PARTITION_NUMBER: subconfig_entry.data[CONF_PARTITION_NUMBER]
+ },
+ )
+
+
+class ZoneSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying a zone."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add new zone."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ unique_id = f"{SUBENTRY_TYPE_ZONE}_{user_input[CONF_ZONE_NUMBER]}"
+
+ for existing_subentry in self._get_entry().subentries.values():
+ if existing_subentry.unique_id == unique_id:
+ errors[CONF_ZONE_NUMBER] = "already_configured"
+
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_ZONE_NUMBER): vol.All(
+ vol.Coerce(int), vol.Range(min=1)
+ ),
+ }
+ ).extend(ZONE_AND_OUTPUT_SCHEMA.schema),
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure existing zone."""
+ subconfig_entry = self._get_reconfigure_subentry()
+
+ if user_input is not None:
+ return self.async_update_and_abort(
+ self._get_entry(),
+ subconfig_entry,
+ title=user_input[CONF_NAME],
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ ZONE_AND_OUTPUT_SCHEMA, subconfig_entry.data
+ ),
+ description_placeholders={
+ CONF_ZONE_NUMBER: subconfig_entry.data[CONF_ZONE_NUMBER]
+ },
+ )
+
+
+class OutputSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying a output."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add new output."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ unique_id = f"{SUBENTRY_TYPE_OUTPUT}_{user_input[CONF_OUTPUT_NUMBER]}"
+
+ for existing_subentry in self._get_entry().subentries.values():
+ if existing_subentry.unique_id == unique_id:
+ errors[CONF_OUTPUT_NUMBER] = "already_configured"
+
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_OUTPUT_NUMBER): vol.All(
+ vol.Coerce(int), vol.Range(min=1)
+ ),
+ }
+ ).extend(ZONE_AND_OUTPUT_SCHEMA.schema),
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure existing output."""
+ subconfig_entry = self._get_reconfigure_subentry()
+
+ if user_input is not None:
+ return self.async_update_and_abort(
+ self._get_entry(),
+ subconfig_entry,
+ title=user_input[CONF_NAME],
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ ZONE_AND_OUTPUT_SCHEMA, subconfig_entry.data
+ ),
+ description_placeholders={
+ CONF_OUTPUT_NUMBER: subconfig_entry.data[CONF_OUTPUT_NUMBER]
+ },
+ )
+
+
+class SwitchableOutputSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow for adding and modifying a switchable output."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to add new switchable output."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ unique_id = f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]}"
+
+ for existing_subentry in self._get_entry().subentries.values():
+ if existing_subentry.unique_id == unique_id:
+ errors[CONF_SWITCHABLE_OUTPUT_NUMBER] = "already_configured"
+
+ if not errors:
+ return self.async_create_entry(
+ title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_SWITCHABLE_OUTPUT_NUMBER): vol.All(
+ vol.Coerce(int), vol.Range(min=1)
+ ),
+ }
+ ).extend(SWITCHABLE_OUTPUT_SCHEMA.schema),
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure existing switchable output."""
+ subconfig_entry = self._get_reconfigure_subentry()
+
+ if user_input is not None:
+ return self.async_update_and_abort(
+ self._get_entry(),
+ subconfig_entry,
+ title=user_input[CONF_NAME],
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=self.add_suggested_values_to_schema(
+ SWITCHABLE_OUTPUT_SCHEMA, subconfig_entry.data
+ ),
+ description_placeholders={
+ CONF_SWITCHABLE_OUTPUT_NUMBER: subconfig_entry.data[
+ CONF_SWITCHABLE_OUTPUT_NUMBER
+ ]
+ },
+ )
diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py
new file mode 100644
index 00000000000..822fbe7594b
--- /dev/null
+++ b/homeassistant/components/satel_integra/const.py
@@ -0,0 +1,38 @@
+"""Constants for the Satel Integra integration."""
+
+from satel_integra.satel_integra import AsyncSatel
+
+from homeassistant.config_entries import ConfigEntry
+
+DEFAULT_CONF_ARM_HOME_MODE = 1
+DEFAULT_PORT = 7094
+DEFAULT_ZONE_TYPE = "motion"
+
+DOMAIN = "satel_integra"
+
+SUBENTRY_TYPE_PARTITION = "partition"
+SUBENTRY_TYPE_ZONE = "zone"
+SUBENTRY_TYPE_OUTPUT = "output"
+SUBENTRY_TYPE_SWITCHABLE_OUTPUT = "switchable_output"
+
+CONF_PARTITION_NUMBER = "partition_number"
+CONF_ZONE_NUMBER = "zone_number"
+CONF_OUTPUT_NUMBER = "output_number"
+CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number"
+
+CONF_DEVICE_PARTITIONS = "partitions"
+CONF_ARM_HOME_MODE = "arm_home_mode"
+CONF_ZONE_TYPE = "type"
+CONF_ZONES = "zones"
+CONF_OUTPUTS = "outputs"
+CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
+
+ZONES = "zones"
+
+
+SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message"
+
+SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated"
+SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated"
+
+type SatelConfigEntry = ConfigEntry[AsyncSatel]
diff --git a/homeassistant/components/satel_integra/diagnostics.py b/homeassistant/components/satel_integra/diagnostics.py
new file mode 100644
index 00000000000..93e9bd104ee
--- /dev/null
+++ b/homeassistant/components/satel_integra/diagnostics.py
@@ -0,0 +1,26 @@
+"""Diagnostics support for Satel Integra."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_CODE
+from homeassistant.core import HomeAssistant
+
+TO_REDACT = {CONF_CODE}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for the config entry."""
+ diag: dict[str, Any] = {}
+
+ diag["config_entry_data"] = dict(entry.data)
+ diag["config_entry_options"] = async_redact_data(entry.options, TO_REDACT)
+
+ diag["subentries"] = dict(entry.subentries)
+
+ return diag
diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json
index a90ea1db5a5..0e5e9edbb2c 100644
--- a/homeassistant/components/satel_integra/manifest.json
+++ b/homeassistant/components/satel_integra/manifest.json
@@ -1,10 +1,11 @@
{
"domain": "satel_integra",
"name": "Satel Integra",
- "codeowners": [],
+ "codeowners": ["@Tommatheussen"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/satel_integra",
"iot_class": "local_push",
"loggers": ["satel_integra"],
- "quality_scale": "legacy",
- "requirements": ["satel-integra==0.3.7"]
+ "requirements": ["satel-integra==0.3.7"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/satel_integra/quality_scale.yaml b/homeassistant/components/satel_integra/quality_scale.yaml
new file mode 100644
index 00000000000..dc1c269dea2
--- /dev/null
+++ b/homeassistant/components/satel_integra/quality_scale.yaml
@@ -0,0 +1,66 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: This integration does not provide any service actions.
+ appropriate-polling:
+ status: exempt
+ comment: This integration does not poll.
+ brands: done
+ common-modules: todo
+ config-flow-test-coverage: todo
+ config-flow: todo
+ dependency-transparency: todo
+ docs-actions:
+ status: exempt
+ comment: This integration does not provide any service actions.
+ docs-high-level-description: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup: todo
+ entity-unique-id: todo
+ has-entity-name: todo
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: todo
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: todo
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: todo
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json
new file mode 100644
index 00000000000..1d6655145b5
--- /dev/null
+++ b/homeassistant/components/satel_integra/strings.json
@@ -0,0 +1,210 @@
+{
+ "common": {
+ "code_input_description": "Code to toggle switchable outputs",
+ "code": "Access code"
+ },
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "code": "[%key:component::satel_integra::common::code%]"
+ },
+ "data_description": {
+ "host": "The IP address of the alarm panel",
+ "port": "The port of the alarm panel",
+ "code": "[%key:component::satel_integra::common::code_input_description%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ }
+ },
+ "config_subentries": {
+ "partition": {
+ "initiate_flow": {
+ "user": "Add partition"
+ },
+ "step": {
+ "user": {
+ "title": "Configure partition",
+ "data": {
+ "partition_number": "Partition number",
+ "name": "[%key:common::config_flow::data::name%]",
+ "arm_home_mode": "Arm home mode"
+ },
+ "data_description": {
+ "partition_number": "Enter partition number to configure",
+ "name": "The name to give to the alarm panel",
+ "arm_home_mode": "The mode in which the partition is armed when 'arm home' is used. For more information on what the differences are between them, please refer to Satel Integra manual."
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure partition {partition_number}",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "arm_home_mode": "[%key:component::satel_integra::config_subentries::partition::step::user::data::arm_home_mode%]"
+ },
+ "data_description": {
+ "arm_home_mode": "[%key:component::satel_integra::config_subentries::partition::step::user::data_description::arm_home_mode%]"
+ }
+ }
+ },
+ "error": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "zone": {
+ "initiate_flow": {
+ "user": "Add zone"
+ },
+ "step": {
+ "user": {
+ "title": "Configure zone",
+ "data": {
+ "zone_number": "Zone number",
+ "name": "[%key:common::config_flow::data::name%]",
+ "type": "Zone type"
+ },
+ "data_description": {
+ "zone_number": "Enter zone number to configure",
+ "name": "The name to give to the sensor",
+ "type": "Choose the device class you would like the sensor to show as"
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure zone {zone_number}",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data::type%]"
+ },
+ "data_description": {
+ "name": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::name%]",
+ "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::type%]"
+ }
+ }
+ },
+ "error": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "output": {
+ "initiate_flow": {
+ "user": "Add output"
+ },
+ "step": {
+ "user": {
+ "title": "Configure output",
+ "data": {
+ "output_number": "Output number",
+ "name": "[%key:common::config_flow::data::name%]",
+ "type": "Output type"
+ },
+ "data_description": {
+ "output_number": "Enter output number to configure",
+ "name": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::name%]",
+ "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::type%]"
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure output {output_number}",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "type": "[%key:component::satel_integra::config_subentries::output::step::user::data::type%]"
+ },
+ "data_description": {
+ "name": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::name%]",
+ "type": "[%key:component::satel_integra::config_subentries::zone::step::user::data_description::type%]"
+ }
+ }
+ },
+ "error": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "switchable_output": {
+ "initiate_flow": {
+ "user": "Add switchable output"
+ },
+ "step": {
+ "user": {
+ "title": "Configure switchable output",
+ "data": {
+ "switchable_output_number": "Switchable output number",
+ "name": "[%key:common::config_flow::data::name%]"
+ },
+ "data_description": {
+ "switchable_output_number": "Enter switchable output number to configure",
+ "name": "The name to give to the switch"
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure switchable output {switchable_output_number}",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]"
+ },
+ "data_description": {
+ "name": "[%key:component::satel_integra::config_subentries::switchable_output::step::user::data_description::name%]"
+ }
+ }
+ },
+ "error": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "[%key:component::satel_integra::common::code%]"
+ },
+ "data_description": {
+ "code": "[%key:component::satel_integra::common::code_input_description%]"
+ }
+ }
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "title": "YAML import failed due to a connection error",
+ "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your existing configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the `{domain}` YAML configuration from your configuration.yaml file and add the {integration_title} integration manually."
+ }
+ },
+ "selector": {
+ "binary_sensor_device_class": {
+ "options": {
+ "battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
+ "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]",
+ "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]",
+ "cold": "[%key:component::binary_sensor::entity_component::cold::name%]",
+ "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]",
+ "door": "[%key:component::binary_sensor::entity_component::door::name%]",
+ "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]",
+ "gas": "[%key:component::binary_sensor::entity_component::gas::name%]",
+ "heat": "[%key:component::binary_sensor::entity_component::heat::name%]",
+ "light": "[%key:component::binary_sensor::entity_component::light::name%]",
+ "lock": "[%key:component::binary_sensor::entity_component::lock::name%]",
+ "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]",
+ "motion": "[%key:component::binary_sensor::entity_component::motion::name%]",
+ "moving": "[%key:component::binary_sensor::entity_component::moving::name%]",
+ "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]",
+ "opening": "[%key:component::binary_sensor::entity_component::opening::name%]",
+ "plug": "[%key:component::binary_sensor::entity_component::plug::name%]",
+ "power": "[%key:component::binary_sensor::entity_component::power::name%]",
+ "presence": "[%key:component::binary_sensor::entity_component::presence::name%]",
+ "problem": "[%key:component::binary_sensor::entity_component::problem::name%]",
+ "running": "[%key:component::binary_sensor::entity_component::running::name%]",
+ "safety": "[%key:component::binary_sensor::entity_component::safety::name%]",
+ "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]",
+ "sound": "[%key:component::binary_sensor::entity_component::sound::name%]",
+ "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]",
+ "update": "[%key:component::binary_sensor::entity_component::update::name%]",
+ "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]",
+ "window": "[%key:component::binary_sensor::entity_component::window::name%]"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py
index 9135b58bc50..85139069ce6 100644
--- a/homeassistant/components/satel_integra/switch.py
+++ b/homeassistant/components/satel_integra/switch.py
@@ -6,48 +6,50 @@ import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
+from homeassistant.const import CONF_CODE, CONF_NAME
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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import (
- CONF_DEVICE_CODE,
- CONF_SWITCHABLE_OUTPUTS,
- CONF_ZONE_NAME,
- DATA_SATEL,
+from .const import (
+ CONF_SWITCHABLE_OUTPUT_NUMBER,
SIGNAL_OUTPUTS_UPDATED,
+ SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
+ SatelConfigEntry,
)
_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = ["satel_integra"]
-
-async def async_setup_platform(
+async def async_setup_entry(
hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
+ config_entry: SatelConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Satel Integra switch devices."""
- if not discovery_info:
- return
- configured_zones = discovery_info[CONF_SWITCHABLE_OUTPUTS]
- controller = hass.data[DATA_SATEL]
+ controller = config_entry.runtime_data
- devices = []
+ switchable_output_subentries = filter(
+ lambda entry: entry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
+ config_entry.subentries.values(),
+ )
- for zone_num, device_config_data in configured_zones.items():
- zone_name = device_config_data[CONF_ZONE_NAME]
+ for subentry in switchable_output_subentries:
+ switchable_output_num = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
+ switchable_output_name = subentry.data[CONF_NAME]
- device = SatelIntegraSwitch(
- controller, zone_num, zone_name, discovery_info[CONF_DEVICE_CODE]
+ async_add_entities(
+ [
+ SatelIntegraSwitch(
+ controller,
+ switchable_output_num,
+ switchable_output_name,
+ config_entry.options.get(CONF_CODE),
+ ),
+ ],
+ config_subentry_id=subentry.subentry_id,
)
- devices.append(device)
-
- async_add_entities(devices)
class SatelIntegraSwitch(SwitchEntity):
diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py
index d1b34b50770..b4e23a36d82 100644
--- a/homeassistant/components/scene/__init__.py
+++ b/homeassistant/components/scene/__init__.py
@@ -12,15 +12,16 @@ import voluptuous as vol
from homeassistant.components.light import ATTR_TRANSITION
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
+from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "scene"
-DATA_COMPONENT: HassKey[EntityComponent[Scene]] = HassKey(DOMAIN)
+DATA_COMPONENT: HassKey[EntityComponent[BaseScene]] = HassKey(DOMAIN)
STATES: Final = "states"
@@ -62,7 +63,7 @@ PLATFORM_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the scenes."""
- component = hass.data[DATA_COMPONENT] = EntityComponent[Scene](
+ component = hass.data[DATA_COMPONENT] = EntityComponent[BaseScene](
logging.getLogger(__name__), DOMAIN, hass
)
@@ -93,8 +94,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
-class Scene(RestoreEntity):
- """A scene is a group of entities and the states we want them to be."""
+class BaseScene(RestoreEntity):
+ """Base type for scenes."""
_attr_should_poll = False
__last_activated: str | None = None
@@ -108,14 +109,14 @@ class Scene(RestoreEntity):
return self.__last_activated
@final
- async def _async_activate(self, **kwargs: Any) -> None:
- """Activate scene.
+ def _record_activation(self) -> None:
+ run_callback_threadsafe(self.hass.loop, self._async_record_activation).result()
- Should not be overridden, handle setting last press timestamp.
- """
+ @final
+ @callback
+ def _async_record_activation(self) -> None:
+ """Update the activation timestamp."""
self.__last_activated = dt_util.utcnow().isoformat()
- self.async_write_ha_state()
- await self.async_activate(**kwargs)
async def async_internal_added_to_hass(self) -> None:
"""Call when the scene is added to hass."""
@@ -128,6 +129,10 @@ class Scene(RestoreEntity):
):
self.__last_activated = state.state
+ async def _async_activate(self, **kwargs: Any) -> None:
+ """Activate scene."""
+ raise NotImplementedError
+
def activate(self, **kwargs: Any) -> None:
"""Activate scene. Try to get entities into requested state."""
raise NotImplementedError
@@ -137,3 +142,17 @@ class Scene(RestoreEntity):
task = self.hass.async_add_executor_job(ft.partial(self.activate, **kwargs))
if task:
await task
+
+
+class Scene(BaseScene):
+ """A scene is a group of entities and the states we want them to be."""
+
+ @final
+ async def _async_activate(self, **kwargs: Any) -> None:
+ """Activate scene.
+
+ Should not be overridden, handle setting last press timestamp.
+ """
+ self._async_record_activation()
+ self.async_write_ha_state()
+ await self.async_activate(**kwargs)
diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json
index b71afe01e56..eadf5585f30 100644
--- a/homeassistant/components/schlage/manifest.json
+++ b/homeassistant/components/schlage/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
- "requirements": ["pyschlage==2025.7.3"]
+ "requirements": ["pyschlage==2025.9.0"]
}
diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py
index 801140157c1..5c39b57f785 100644
--- a/homeassistant/components/scrape/__init__.py
+++ b/homeassistant/components/scrape/__init__.py
@@ -78,7 +78,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
scan_interval: timedelta = resource_config.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
- coordinator = ScrapeCoordinator(hass, None, rest, scan_interval)
+ coordinator = ScrapeCoordinator(
+ hass, None, rest, resource_config, scan_interval
+ )
sensors: list[ConfigType] = resource_config.get(SENSOR_DOMAIN, [])
if sensors:
@@ -108,13 +110,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo
hass,
entry,
rest,
+ rest_config,
DEFAULT_SCAN_INTERVAL,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -124,11 +126,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
) -> bool:
diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py
index 017b3c707a9..edb5a6160bf 100644
--- a/homeassistant/components/scrape/config_flow.py
+++ b/homeassistant/components/scrape/config_flow.py
@@ -308,6 +308,7 @@ class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/scrape/coordinator.py b/homeassistant/components/scrape/coordinator.py
index b5cabc6b94e..07566c968f1 100644
--- a/homeassistant/components/scrape/coordinator.py
+++ b/homeassistant/components/scrape/coordinator.py
@@ -4,11 +4,14 @@ from __future__ import annotations
from datetime import timedelta
import logging
+from typing import Any
from bs4 import BeautifulSoup
from homeassistant.components.rest import RestData
+from homeassistant.components.rest.const import CONF_PAYLOAD_TEMPLATE
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_RESOURCE_TEMPLATE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -23,6 +26,7 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]):
hass: HomeAssistant,
config_entry: ConfigEntry | None,
rest: RestData,
+ rest_config: dict[str, Any],
update_interval: timedelta,
) -> None:
"""Initialize Scrape coordinator."""
@@ -34,9 +38,18 @@ class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]):
update_interval=update_interval,
)
self._rest = rest
+ self._rest_config = rest_config
async def _async_update_data(self) -> BeautifulSoup:
"""Fetch data from Rest."""
+ if CONF_RESOURCE_TEMPLATE in self._rest_config:
+ self._rest.set_url(
+ self._rest_config["resource_template"].async_render(parse_result=False)
+ )
+ if CONF_PAYLOAD_TEMPLATE in self._rest_config:
+ self._rest.set_payload(
+ self._rest_config["payload_template"].async_render(parse_result=False)
+ )
await self._rest.async_update()
if (data := self._rest.data) is None:
raise UpdateFailed("REST data is not available")
diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json
index 91452287ce7..7faa3ec91db 100644
--- a/homeassistant/components/scrape/strings.json
+++ b/homeassistant/components/scrape/strings.json
@@ -171,6 +171,7 @@
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
@@ -178,6 +179,7 @@
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
+ "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
diff --git a/homeassistant/components/select/services.yaml b/homeassistant/components/select/services.yaml
index dc6d4c6815a..4f655422f6f 100644
--- a/homeassistant/components/select/services.yaml
+++ b/homeassistant/components/select/services.yaml
@@ -27,7 +27,10 @@ select_option:
required: true
example: '"Item A"'
selector:
- text:
+ state:
+ hide_states:
+ - unavailable
+ - unknown
select_previous:
target:
diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py
index 06b5ea6588a..35750cd28bf 100644
--- a/homeassistant/components/sensibo/__init__.py
+++ b/homeassistant/components/sensibo/__init__.py
@@ -8,15 +8,26 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
+from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, PLATFORMS
from .coordinator import SensiboDataUpdateCoordinator
+from .services import async_setup_services
from .util import NoDevicesError, NoUsernameError, async_validate_api
type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Sensibo component."""
+ async_setup_services(hass)
+
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool:
"""Set up Sensibo from a config entry."""
diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py
index a40cb110f66..daffad0447a 100644
--- a/homeassistant/components/sensibo/climate.py
+++ b/homeassistant/components/sensibo/climate.py
@@ -5,26 +5,14 @@ from __future__ import annotations
from bisect import bisect_left
from typing import TYPE_CHECKING, Any
-import voluptuous as vol
-
from homeassistant.components.climate import (
- ATTR_FAN_MODE,
- ATTR_HVAC_MODE,
- ATTR_SWING_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import (
- ATTR_MODE,
- ATTR_STATE,
- ATTR_TEMPERATURE,
- PRECISION_TENTHS,
- UnitOfTemperature,
-)
-from homeassistant.core import HomeAssistant, SupportsResponse
+from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -33,30 +21,6 @@ from .const import DOMAIN
from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity, async_handle_api_call
-SERVICE_ASSUME_STATE = "assume_state"
-SERVICE_ENABLE_TIMER = "enable_timer"
-ATTR_MINUTES = "minutes"
-SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost"
-SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost"
-SERVICE_FULL_STATE = "full_state"
-SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react"
-SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities"
-ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold"
-ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state"
-ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold"
-ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state"
-ATTR_SMART_TYPE = "smart_type"
-
-ATTR_AC_INTEGRATION = "ac_integration"
-ATTR_GEO_INTEGRATION = "geo_integration"
-ATTR_INDOOR_INTEGRATION = "indoor_integration"
-ATTR_OUTDOOR_INTEGRATION = "outdoor_integration"
-ATTR_SENSITIVITY = "sensitivity"
-ATTR_TARGET_TEMPERATURE = "target_temperature"
-ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode"
-ATTR_LIGHT = "light"
-BOOST_INCLUSIVE = "boost_inclusive"
-
AVAILABLE_FAN_MODES = {
"quiet",
"low",
@@ -162,66 +126,6 @@ async def async_setup_entry(
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
- platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(
- SERVICE_ASSUME_STATE,
- {
- vol.Required(ATTR_STATE): vol.In(["on", "off"]),
- },
- "async_assume_state",
- )
- platform.async_register_entity_service(
- SERVICE_ENABLE_TIMER,
- {
- vol.Required(ATTR_MINUTES): cv.positive_int,
- },
- "async_enable_timer",
- )
- platform.async_register_entity_service(
- SERVICE_ENABLE_PURE_BOOST,
- {
- vol.Required(ATTR_AC_INTEGRATION): bool,
- vol.Required(ATTR_GEO_INTEGRATION): bool,
- vol.Required(ATTR_INDOOR_INTEGRATION): bool,
- vol.Required(ATTR_OUTDOOR_INTEGRATION): bool,
- vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]),
- },
- "async_enable_pure_boost",
- )
- platform.async_register_entity_service(
- SERVICE_FULL_STATE,
- {
- vol.Required(ATTR_MODE): vol.In(
- ["cool", "heat", "fan", "auto", "dry", "off"]
- ),
- vol.Optional(ATTR_TARGET_TEMPERATURE): int,
- vol.Optional(ATTR_FAN_MODE): str,
- vol.Optional(ATTR_SWING_MODE): str,
- vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str,
- vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]),
- },
- "async_full_ac_state",
- )
- platform.async_register_entity_service(
- SERVICE_ENABLE_CLIMATE_REACT,
- {
- vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float),
- vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict,
- vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
- vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
- vol.Required(ATTR_SMART_TYPE): vol.In(
- ["temperature", "feelslike", "humidity"]
- ),
- },
- "async_enable_climate_react",
- )
- platform.async_register_entity_service(
- SERVICE_GET_DEVICE_CAPABILITIES,
- {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)},
- "async_get_device_capabilities",
- supports_response=SupportsResponse.ONLY,
- )
-
class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"""Representation of a Sensibo climate device."""
diff --git a/homeassistant/components/sensibo/services.py b/homeassistant/components/sensibo/services.py
new file mode 100644
index 00000000000..682954e6d7c
--- /dev/null
+++ b/homeassistant/components/sensibo/services.py
@@ -0,0 +1,124 @@
+"""Sensibo services."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.components.climate import (
+ ATTR_FAN_MODE,
+ ATTR_HVAC_MODE,
+ ATTR_SWING_MODE,
+ DOMAIN as CLIMATE_DOMAIN,
+ HVACMode,
+)
+from homeassistant.const import ATTR_MODE, ATTR_STATE
+from homeassistant.core import HomeAssistant, SupportsResponse, callback
+from homeassistant.helpers import config_validation as cv, service
+
+from .const import DOMAIN
+
+SERVICE_ASSUME_STATE = "assume_state"
+SERVICE_ENABLE_TIMER = "enable_timer"
+ATTR_MINUTES = "minutes"
+SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost"
+SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost"
+SERVICE_FULL_STATE = "full_state"
+SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react"
+SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities"
+ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold"
+ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state"
+ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold"
+ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state"
+ATTR_SMART_TYPE = "smart_type"
+
+ATTR_AC_INTEGRATION = "ac_integration"
+ATTR_GEO_INTEGRATION = "geo_integration"
+ATTR_INDOOR_INTEGRATION = "indoor_integration"
+ATTR_OUTDOOR_INTEGRATION = "outdoor_integration"
+ATTR_SENSITIVITY = "sensitivity"
+ATTR_TARGET_TEMPERATURE = "target_temperature"
+ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode"
+ATTR_LIGHT = "light"
+BOOST_INCLUSIVE = "boost_inclusive"
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register Sensibo services."""
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_ASSUME_STATE,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={
+ vol.Required(ATTR_STATE): vol.In(["on", "off"]),
+ },
+ func="async_assume_state",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_ENABLE_TIMER,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={
+ vol.Required(ATTR_MINUTES): cv.positive_int,
+ },
+ func="async_enable_timer",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_ENABLE_PURE_BOOST,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={
+ vol.Required(ATTR_AC_INTEGRATION): bool,
+ vol.Required(ATTR_GEO_INTEGRATION): bool,
+ vol.Required(ATTR_INDOOR_INTEGRATION): bool,
+ vol.Required(ATTR_OUTDOOR_INTEGRATION): bool,
+ vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]),
+ },
+ func="async_enable_pure_boost",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_FULL_STATE,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={
+ vol.Required(ATTR_MODE): vol.In(
+ ["cool", "heat", "fan", "auto", "dry", "off"]
+ ),
+ vol.Optional(ATTR_TARGET_TEMPERATURE): int,
+ vol.Optional(ATTR_FAN_MODE): str,
+ vol.Optional(ATTR_SWING_MODE): str,
+ vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str,
+ vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]),
+ },
+ func="async_full_ac_state",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_ENABLE_CLIMATE_REACT,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={
+ vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float),
+ vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict,
+ vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
+ vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
+ vol.Required(ATTR_SMART_TYPE): vol.In(
+ ["temperature", "feelslike", "humidity"]
+ ),
+ },
+ func="async_enable_climate_react",
+ )
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_GET_DEVICE_CAPABILITIES,
+ entity_domain=CLIMATE_DOMAIN,
+ schema={vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)},
+ func="async_get_device_capabilities",
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 56171707338..d6829d35329 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -361,25 +361,30 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool:
"""Validate the suggested unit.
- Validate that a unit converter exists for the sensor's device class and that the
- unit converter supports both the native and the suggested units of measurement.
+ Validate that the native unit of measurement can be converted to the
+ suggested unit of measurement, either because they are the same or
+ because a unit converter supports both.
"""
- # Make sure we can convert the units
- if (
- (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None
- or self.__native_unit_of_measurement_compat
- not in unit_converter.VALID_UNITS
- or suggested_unit_of_measurement not in unit_converter.VALID_UNITS
- ):
- if not self._invalid_suggested_unit_of_measurement_reported:
- self._invalid_suggested_unit_of_measurement_reported = True
- raise ValueError(
- f"Entity {type(self)} suggest an incorrect "
- f"unit of measurement: {suggested_unit_of_measurement}."
- )
- return False
+ # No need to check the unit converter if the units are the same
+ if self.__native_unit_of_measurement_compat == suggested_unit_of_measurement:
+ return True
- return True
+ # Make sure there is a unit converter and it supports both units
+ if (
+ (unit_converter := UNIT_CONVERTERS.get(self.device_class))
+ and self.__native_unit_of_measurement_compat in unit_converter.VALID_UNITS
+ and suggested_unit_of_measurement in unit_converter.VALID_UNITS
+ ):
+ return True
+
+ # Report invalid suggested unit only once per entity
+ if not self._invalid_suggested_unit_of_measurement_reported:
+ self._invalid_suggested_unit_of_measurement_reported = True
+ raise ValueError(
+ f"Entity {type(self)} suggest an incorrect "
+ f"unit of measurement: {suggested_unit_of_measurement}."
+ )
+ return False
def _get_initial_suggested_unit(self) -> str | UndefinedType:
"""Return the initial unit."""
@@ -473,7 +478,11 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@property
def __native_unit_of_measurement_compat(self) -> str | None:
- """Process ambiguous units."""
+ """Handle wrong character coding in unit provided by integrations.
+
+ SensorEntity should read the sensor's native unit through this property instead
+ of through native_unit_of_measurement.
+ """
native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get(
native_unit_of_measurement,
@@ -853,16 +862,25 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return a custom unit, or UNDEFINED if not compatible with the native unit."""
assert self.registry_entry
if (
- (sensor_options := self.registry_entry.options.get(primary_key))
- and secondary_key in sensor_options
- and (device_class := self.device_class) in UNIT_CONVERTERS
- and self.__native_unit_of_measurement_compat
- in UNIT_CONVERTERS[device_class].VALID_UNITS
- and (custom_unit := sensor_options[secondary_key])
- in UNIT_CONVERTERS[device_class].VALID_UNITS
+ sensor_options := self.registry_entry.options.get(primary_key)
+ ) is None or secondary_key not in sensor_options:
+ return UNDEFINED
+
+ if (device_class := self.device_class) not in UNIT_CONVERTERS:
+ return UNDEFINED
+
+ if (
+ self.__native_unit_of_measurement_compat
+ not in UNIT_CONVERTERS[device_class].VALID_UNITS
):
- return cast(str, custom_unit)
- return UNDEFINED
+ return UNDEFINED
+
+ if (custom_unit := sensor_options[secondary_key]) not in UNIT_CONVERTERS[
+ device_class
+ ].VALID_UNITS:
+ return UNDEFINED
+
+ return cast(str, custom_unit)
@callback
def async_registry_entry_updated(self) -> None:
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index af35b8127eb..87ddf4445a0 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -120,7 +120,7 @@ class SensorDeviceClass(StrEnum):
APPARENT_POWER = "apparent_power"
"""Apparent power.
- Unit of measurement: `mVA`, `VA`
+ Unit of measurement: `mVA`, `VA`, `kVA`
"""
AQI = "aqi"
@@ -240,7 +240,7 @@ class SensorDeviceClass(StrEnum):
Unit of measurement:
- SI / metric: `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`
+ - USCS / imperial: `ft³`, `CCF`, `MCF`
"""
HUMIDITY = "humidity"
@@ -325,6 +325,12 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `μg/m³`
"""
+ PM4 = "pm4"
+ """Particulate matter <= 4 μm.
+
+ Unit of measurement: `μg/m³`
+ """
+
POWER_FACTOR = "power_factor"
"""Power factor.
@@ -361,6 +367,7 @@ class SensorDeviceClass(StrEnum):
- `Pa`, `hPa`, `kPa`
- `inHg`
- `psi`
+ - `inH₂O`
"""
REACTIVE_ENERGY = "reactive_energy"
@@ -432,7 +439,7 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `VOLUME_*` units
- SI / metric: `mL`, `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -444,7 +451,7 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `VOLUME_*` units
- SI / metric: `mL`, `L`, `m³`
- - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -461,7 +468,7 @@ class SensorDeviceClass(StrEnum):
Unit of measurement:
- SI / metric: `m³`, `L`
- - USCS / imperial: `ft³`, `CCF`, `gal` (warning: volumes expressed in
+ - USCS / imperial: `ft³`, `CCF`, `MCF`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
@@ -601,6 +608,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
},
SensorDeviceClass.HUMIDITY: {PERCENTAGE},
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
@@ -614,6 +622,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ SensorDeviceClass.PM4: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
SensorDeviceClass.POWER: {
UnitOfPower.MILLIWATT,
@@ -654,6 +663,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.GALLONS,
UnitOfVolume.LITERS,
+ UnitOfVolume.MILLE_CUBIC_FEET,
},
SensorDeviceClass.WEIGHT: set(UnitOfMass),
SensorDeviceClass.WIND_DIRECTION: {DEGREE},
@@ -747,6 +757,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorDeviceClass.PM1: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.PM10: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT},
+ SensorDeviceClass.PM4: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.POWER_FACTOR: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.POWER: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.PRECIPITATION: set(SensorStateClass),
diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index 1ad5fe12e99..e238b1d9a9b 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -65,6 +65,7 @@ CONF_IS_PH = "is_ph"
CONF_IS_PM1 = "is_pm1"
CONF_IS_PM10 = "is_pm10"
CONF_IS_PM25 = "is_pm25"
+CONF_IS_PM4 = "is_pm4"
CONF_IS_POWER = "is_power"
CONF_IS_POWER_FACTOR = "is_power_factor"
CONF_IS_PRECIPITATION = "is_precipitation"
@@ -126,6 +127,7 @@ ENTITY_CONDITIONS = {
SensorDeviceClass.PM1: [{CONF_TYPE: CONF_IS_PM1}],
SensorDeviceClass.PM10: [{CONF_TYPE: CONF_IS_PM10}],
SensorDeviceClass.PM25: [{CONF_TYPE: CONF_IS_PM25}],
+ SensorDeviceClass.PM4: [{CONF_TYPE: CONF_IS_PM4}],
SensorDeviceClass.PRECIPITATION: [{CONF_TYPE: CONF_IS_PRECIPITATION}],
SensorDeviceClass.PRECIPITATION_INTENSITY: [
{CONF_TYPE: CONF_IS_PRECIPITATION_INTENSITY}
@@ -195,6 +197,7 @@ CONDITION_SCHEMA = vol.All(
CONF_IS_PM1,
CONF_IS_PM10,
CONF_IS_PM25,
+ CONF_IS_PM4,
CONF_IS_PRECIPITATION,
CONF_IS_PRECIPITATION_INTENSITY,
CONF_IS_PRESSURE,
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index ae2125962e8..1aacdbf507f 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -64,6 +64,7 @@ CONF_PH = "ph"
CONF_PM1 = "pm1"
CONF_PM10 = "pm10"
CONF_PM25 = "pm25"
+CONF_PM4 = "pm4"
CONF_POWER = "power"
CONF_POWER_FACTOR = "power_factor"
CONF_PRECIPITATION = "precipitation"
@@ -123,6 +124,7 @@ ENTITY_TRIGGERS = {
SensorDeviceClass.PM1: [{CONF_TYPE: CONF_PM1}],
SensorDeviceClass.PM10: [{CONF_TYPE: CONF_PM10}],
SensorDeviceClass.PM25: [{CONF_TYPE: CONF_PM25}],
+ SensorDeviceClass.PM4: [{CONF_TYPE: CONF_PM4}],
SensorDeviceClass.POWER: [{CONF_TYPE: CONF_POWER}],
SensorDeviceClass.POWER_FACTOR: [{CONF_TYPE: CONF_POWER_FACTOR}],
SensorDeviceClass.PRECIPITATION: [{CONF_TYPE: CONF_PRECIPITATION}],
@@ -193,6 +195,7 @@ TRIGGER_SCHEMA = vol.All(
CONF_PM1,
CONF_PM10,
CONF_PM25,
+ CONF_PM4,
CONF_POWER,
CONF_POWER_FACTOR,
CONF_PRECIPITATION,
diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json
index cea955e061c..740b2df7e5b 100644
--- a/homeassistant/components/sensor/icons.json
+++ b/homeassistant/components/sensor/icons.json
@@ -169,6 +169,9 @@
"volume": {
"default": "mdi:car-coolant-level"
},
+ "volume_flow_rate": {
+ "default": "mdi:pipe-valve"
+ },
"volume_storage": {
"default": "mdi:storage-tank"
},
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index a8d06f8c0e9..81a67b78ada 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -34,6 +34,7 @@
"is_pm1": "Current {entity_name} PM1 concentration level",
"is_pm10": "Current {entity_name} PM10 concentration level",
"is_pm25": "Current {entity_name} PM2.5 concentration level",
+ "is_pm4": "Current {entity_name} PM4 concentration level",
"is_power": "Current {entity_name} power",
"is_power_factor": "Current {entity_name} power factor",
"is_precipitation": "Current {entity_name} precipitation",
@@ -90,6 +91,7 @@
"pm1": "{entity_name} PM1 concentration changes",
"pm10": "{entity_name} PM10 concentration changes",
"pm25": "{entity_name} PM2.5 concentration changes",
+ "pm4": "{entity_name} PM4 concentration changes",
"power": "{entity_name} power changes",
"power_factor": "{entity_name} power factor changes",
"precipitation": "{entity_name} precipitation changes",
@@ -243,6 +245,9 @@
"pm1": {
"name": "PM1"
},
+ "pm4": {
+ "name": "PM4"
+ },
"pm10": {
"name": "PM10"
},
diff --git a/homeassistant/components/sftp_storage/__init__.py b/homeassistant/components/sftp_storage/__init__.py
new file mode 100644
index 00000000000..9b095c2decf
--- /dev/null
+++ b/homeassistant/components/sftp_storage/__init__.py
@@ -0,0 +1,155 @@
+"""Integration for SFTP Storage."""
+
+from __future__ import annotations
+
+import contextlib
+from dataclasses import dataclass, field
+import errno
+import logging
+from pathlib import Path
+
+from homeassistant.components.backup import BackupAgentError
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError
+
+from .client import BackupAgentClient
+from .const import (
+ CONF_BACKUP_LOCATION,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_PRIVATE_KEY_FILE,
+ CONF_USERNAME,
+ DATA_BACKUP_AGENT_LISTENERS,
+ DOMAIN,
+ LOGGER,
+)
+
+type SFTPConfigEntry = ConfigEntry[SFTPConfigEntryData]
+
+
+@dataclass(kw_only=True)
+class SFTPConfigEntryData:
+ """Dataclass holding all config entry data for an SFTP Storage entry."""
+
+ host: str
+ port: int
+ username: str
+ password: str | None = field(repr=False)
+ private_key_file: str | None
+ backup_location: str
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> bool:
+ """Set up SFTP Storage from a config entry."""
+
+ cfg = SFTPConfigEntryData(
+ host=entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ username=entry.data[CONF_USERNAME],
+ password=entry.data.get(CONF_PASSWORD),
+ private_key_file=entry.data.get(CONF_PRIVATE_KEY_FILE, []),
+ backup_location=entry.data[CONF_BACKUP_LOCATION],
+ )
+ entry.runtime_data = cfg
+
+ # Establish a connection during setup.
+ # This will raise exception if there is something wrong with either
+ # SSH server or config.
+ try:
+ client = BackupAgentClient(entry, hass)
+ await client.open()
+ except BackupAgentError as e:
+ raise ConfigEntryError from e
+
+ # Notify backup listeners
+ def _async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
+
+ return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> None:
+ """Remove an SFTP Storage config entry."""
+
+ def remove_files(entry: SFTPConfigEntry) -> None:
+ pkey = Path(entry.data[CONF_PRIVATE_KEY_FILE])
+
+ if pkey.exists():
+ LOGGER.debug(
+ "Removing private key (%s) for %s integration for host %s@%s",
+ pkey,
+ DOMAIN,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ )
+ try:
+ pkey.unlink()
+ except OSError as e:
+ LOGGER.warning(
+ "Failed to remove private key %s for %s integration for host %s@%s. %s",
+ pkey.name,
+ DOMAIN,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ str(e),
+ )
+
+ try:
+ pkey.parent.rmdir()
+ except OSError as e:
+ if e.errno == errno.ENOTEMPTY: # Directory not empty
+ if LOGGER.isEnabledFor(logging.DEBUG):
+ leftover_files = []
+ # If we get an exception while gathering leftover files, make sure to log plain message.
+ with contextlib.suppress(OSError):
+ leftover_files = [f.name for f in pkey.parent.iterdir()]
+
+ LOGGER.debug(
+ "Storage directory for %s integration is not empty (%s)%s",
+ DOMAIN,
+ str(pkey.parent),
+ f", files: {', '.join(leftover_files)}"
+ if leftover_files
+ else "",
+ )
+ else:
+ LOGGER.warning(
+ "Error occurred while removing directory %s for integration %s: %s at host %s@%s",
+ str(pkey.parent),
+ DOMAIN,
+ str(e),
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ )
+ else:
+ LOGGER.debug(
+ "Removed storage directory for %s integration",
+ DOMAIN,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ )
+
+ if bool(entry.data.get(CONF_PRIVATE_KEY_FILE)):
+ LOGGER.debug(
+ "Cleaning up after %s integration for host %s@%s",
+ DOMAIN,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ )
+ await hass.async_add_executor_job(remove_files, entry)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: SFTPConfigEntry) -> bool:
+ """Unload SFTP Storage config entry."""
+ LOGGER.debug(
+ "Unloading %s integration for host %s@%s",
+ DOMAIN,
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_HOST],
+ )
+ return True
diff --git a/homeassistant/components/sftp_storage/backup.py b/homeassistant/components/sftp_storage/backup.py
new file mode 100644
index 00000000000..4859f2d2f2a
--- /dev/null
+++ b/homeassistant/components/sftp_storage/backup.py
@@ -0,0 +1,153 @@
+"""Backup platform for the SFTP Storage integration."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+from typing import Any
+
+from asyncssh.sftp import SFTPError
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ BackupNotFound,
+)
+from homeassistant.core import HomeAssistant, callback
+
+from . import SFTPConfigEntry
+from .client import BackupAgentClient
+from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, LOGGER
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Register the backup agents."""
+ entries: list[SFTPConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
+ return [SFTPBackupAgent(hass, entry) for entry in entries]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed."""
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ del hass.data[DATA_BACKUP_AGENT_LISTENERS]
+
+ return remove_listener
+
+
+class SFTPBackupAgent(BackupAgent):
+ """SFTP Backup Storage agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, entry: SFTPConfigEntry) -> None:
+ """Initialize the SFTPBackupAgent backup sync agent."""
+ super().__init__()
+ self._entry: SFTPConfigEntry = entry
+ self._hass: HomeAssistant = hass
+ self.name: str = entry.title
+ self.unique_id: str = entry.entry_id
+
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file from SFTP."""
+ LOGGER.debug(
+ "Establishing SFTP connection to remote host in order to download backup id: %s",
+ backup_id,
+ )
+ try:
+ # Will raise BackupAgentError if failure to authenticate or SFTP Permissions
+ async with BackupAgentClient(self._entry, self._hass) as client:
+ return await client.iter_file(backup_id)
+ except FileNotFoundError as e:
+ raise BackupNotFound(
+ f"Unable to initiate download of backup id: {backup_id}. {e}"
+ ) from e
+
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+ LOGGER.debug("Received request to upload backup: %s", backup)
+ iterator = await open_stream()
+
+ LOGGER.debug(
+ "Establishing SFTP connection to remote host in order to upload backup"
+ )
+
+ # Will raise BackupAgentError if failure to authenticate or SFTP Permissions
+ async with BackupAgentClient(self._entry, self._hass) as client:
+ LOGGER.debug("Uploading backup: %s", backup.backup_id)
+ await client.async_upload_backup(iterator, backup)
+ LOGGER.debug("Successfully uploaded backup id: %s", backup.backup_id)
+
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file from SFTP Storage."""
+ LOGGER.debug("Received request to delete backup id: %s", backup_id)
+
+ try:
+ LOGGER.debug(
+ "Establishing SFTP connection to remote host in order to delete backup"
+ )
+ # Will raise BackupAgentError if failure to authenticate or SFTP Permissions
+ async with BackupAgentClient(self._entry, self._hass) as client:
+ await client.async_delete_backup(backup_id)
+ except FileNotFoundError as err:
+ raise BackupNotFound(str(err)) from err
+ except SFTPError as err:
+ raise BackupAgentError(
+ f"Failed to delete backup id: {backup_id}: {err}"
+ ) from err
+
+ LOGGER.debug("Successfully removed backup id: %s", backup_id)
+
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups stored on SFTP Storage."""
+
+ # Will raise BackupAgentError if failure to authenticate or SFTP Permissions
+ async with BackupAgentClient(self._entry, self._hass) as client:
+ try:
+ return await client.async_list_backups()
+ except SFTPError as err:
+ raise BackupAgentError(
+ f"Remote server error while attempting to list backups: {err}"
+ ) from err
+
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup:
+ """Return a backup."""
+ backups = await self.async_list_backups()
+
+ for backup in backups:
+ if backup.backup_id == backup_id:
+ LOGGER.debug("Returning backup id: %s. %s", backup_id, backup)
+ return backup
+
+ raise BackupNotFound(f"Backup id: {backup_id} not found")
diff --git a/homeassistant/components/sftp_storage/client.py b/homeassistant/components/sftp_storage/client.py
new file mode 100644
index 00000000000..246862f8551
--- /dev/null
+++ b/homeassistant/components/sftp_storage/client.py
@@ -0,0 +1,311 @@
+"""Client for SFTP Storage integration."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+import json
+from types import TracebackType
+from typing import TYPE_CHECKING, Self
+
+from asyncssh import (
+ SFTPClient,
+ SFTPClientFile,
+ SSHClientConnection,
+ SSHClientConnectionOptions,
+ connect,
+)
+from asyncssh.misc import PermissionDenied
+from asyncssh.sftp import SFTPNoSuchFile, SFTPPermissionDenied
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgentError,
+ suggested_filename,
+)
+from homeassistant.core import HomeAssistant
+
+from .const import BUF_SIZE, LOGGER
+
+if TYPE_CHECKING:
+ from . import SFTPConfigEntry, SFTPConfigEntryData
+
+
+def get_client_options(cfg: SFTPConfigEntryData) -> SSHClientConnectionOptions:
+ """Use this function with `hass.async_add_executor_job` to asynchronously get `SSHClientConnectionOptions`."""
+
+ return SSHClientConnectionOptions(
+ known_hosts=None,
+ username=cfg.username,
+ password=cfg.password,
+ client_keys=cfg.private_key_file,
+ )
+
+
+class AsyncFileIterator:
+ """Returns iterator of remote file located in SFTP Server.
+
+ This exists in order to properly close remote file after operation is completed
+ and to avoid premature closing of file and session if `BackupAgentClient` is used
+ as context manager.
+ """
+
+ _client: BackupAgentClient
+ _fileobj: SFTPClientFile
+
+ def __init__(
+ self,
+ cfg: SFTPConfigEntry,
+ hass: HomeAssistant,
+ file_path: str,
+ buffer_size: int = BUF_SIZE,
+ ) -> None:
+ """Initialize `AsyncFileIterator`."""
+ self.cfg: SFTPConfigEntry = cfg
+ self.hass: HomeAssistant = hass
+ self.file_path: str = file_path
+ self.buffer_size = buffer_size
+ self._initialized: bool = False
+ LOGGER.debug("Opening file: %s in Async File Iterator", file_path)
+
+ async def _initialize(self) -> None:
+ """Load file object."""
+ self._client: BackupAgentClient = await BackupAgentClient(
+ self.cfg, self.hass
+ ).open()
+ self._fileobj: SFTPClientFile = await self._client.sftp.open(
+ self.file_path, "rb"
+ )
+
+ self._initialized = True
+
+ def __aiter__(self) -> AsyncIterator[bytes]:
+ """Return self as iterator."""
+ return self
+
+ async def __anext__(self) -> bytes:
+ """Return next bytes as provided in buffer size."""
+ if not self._initialized:
+ await self._initialize()
+
+ chunk: bytes = await self._fileobj.read(self.buffer_size)
+ if not chunk:
+ try:
+ await self._fileobj.close()
+ await self._client.close()
+ finally:
+ raise StopAsyncIteration
+ return chunk
+
+
+@dataclass(kw_only=True)
+class BackupMetadata:
+ """Represent single backup file metadata."""
+
+ file_path: str
+ metadata: dict[str, str | dict[str, list[str]]]
+ metadata_file: str
+
+
+class BackupAgentClient:
+ """Helper class that manages SSH and SFTP Server connections."""
+
+ sftp: SFTPClient
+
+ def __init__(self, config: SFTPConfigEntry, hass: HomeAssistant) -> None:
+ """Initialize `BackupAgentClient`."""
+ self.cfg: SFTPConfigEntry = config
+ self.hass: HomeAssistant = hass
+ self._ssh: SSHClientConnection | None = None
+ LOGGER.debug("Initialized with config: %s", self.cfg.runtime_data)
+
+ async def __aenter__(self) -> Self:
+ """Async context manager entrypoint."""
+
+ return await self.open() # type: ignore[return-value] # mypy will otherwise raise an error
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> None:
+ """Async Context Manager exit routine."""
+ if self.sftp:
+ self.sftp.exit()
+ await self.sftp.wait_closed()
+
+ if self._ssh:
+ self._ssh.close()
+
+ await self._ssh.wait_closed()
+
+ async def _load_metadata(self, backup_id: str) -> BackupMetadata:
+ """Return `BackupMetadata` object`.
+
+ Raises:
+ ------
+ `FileNotFoundError` -- if metadata file is not found.
+
+ """
+
+ # Test for metadata file existence.
+ metadata_file = (
+ f"{self.cfg.runtime_data.backup_location}/.{backup_id}.metadata.json"
+ )
+ if not await self.sftp.exists(metadata_file):
+ raise FileNotFoundError(
+ f"Metadata file not found at remote location: {metadata_file}"
+ )
+
+ async with self.sftp.open(metadata_file, "r") as f:
+ return BackupMetadata(
+ **json.loads(await f.read()), metadata_file=metadata_file
+ )
+
+ async def async_delete_backup(self, backup_id: str) -> None:
+ """Delete backup archive.
+
+ Raises:
+ ------
+ `FileNotFoundError` -- if either metadata file or archive is not found.
+
+ """
+
+ metadata: BackupMetadata = await self._load_metadata(backup_id)
+
+ # If for whatever reason, archive does not exist but metadata file does,
+ # remove the metadata file.
+ if not await self.sftp.exists(metadata.file_path):
+ await self.sftp.unlink(metadata.metadata_file)
+ raise FileNotFoundError(
+ f"File at provided remote location: {metadata.file_path} does not exist."
+ )
+
+ LOGGER.debug("Removing file at path: %s", metadata.file_path)
+ await self.sftp.unlink(metadata.file_path)
+ LOGGER.debug("Removing metadata at path: %s", metadata.metadata_file)
+ await self.sftp.unlink(metadata.metadata_file)
+
+ async def async_list_backups(self) -> list[AgentBackup]:
+ """Iterate through a list of metadata files and return a list of `AgentBackup` objects."""
+
+ backups: list[AgentBackup] = []
+
+ for file in await self.list_backup_location():
+ LOGGER.debug(
+ "Evaluating metadata file at remote location: %s@%s:%s",
+ self.cfg.runtime_data.username,
+ self.cfg.runtime_data.host,
+ file,
+ )
+
+ try:
+ async with self.sftp.open(file, "r") as rfile:
+ metadata = BackupMetadata(
+ **json.loads(await rfile.read()), metadata_file=file
+ )
+ backups.append(AgentBackup.from_dict(metadata.metadata))
+ except (json.JSONDecodeError, TypeError) as e:
+ LOGGER.error(
+ "Failed to load backup metadata from file: %s. %s", file, str(e)
+ )
+ continue
+
+ return backups
+
+ async def async_upload_backup(
+ self,
+ iterator: AsyncIterator[bytes],
+ backup: AgentBackup,
+ ) -> None:
+ """Accept `iterator` as bytes iterator and write backup archive to SFTP Server."""
+
+ file_path = (
+ f"{self.cfg.runtime_data.backup_location}/{suggested_filename(backup)}"
+ )
+ async with self.sftp.open(file_path, "wb") as f:
+ async for b in iterator:
+ await f.write(b)
+
+ LOGGER.debug("Writing backup metadata")
+ metadata: dict[str, str | dict[str, list[str]]] = {
+ "file_path": file_path,
+ "metadata": backup.as_dict(),
+ }
+ async with self.sftp.open(
+ f"{self.cfg.runtime_data.backup_location}/.{backup.backup_id}.metadata.json",
+ "w",
+ ) as f:
+ await f.write(json.dumps(metadata))
+
+ async def close(self) -> None:
+ """Close the `BackupAgentClient` context manager."""
+ await self.__aexit__(None, None, None)
+
+ async def iter_file(self, backup_id: str) -> AsyncFileIterator:
+ """Return Async File Iterator object.
+
+ `SFTPClientFile` object (that would be returned with `sftp.open`) is not an iterator.
+ So we return custom made class - `AsyncFileIterator` that would allow iteration on file object.
+
+ Raises:
+ ------
+ - `FileNotFoundError` -- if metadata or backup archive is not found.
+
+ """
+
+ metadata: BackupMetadata = await self._load_metadata(backup_id)
+ if not await self.sftp.exists(metadata.file_path):
+ raise FileNotFoundError("Backup archive not found on remote location.")
+ return AsyncFileIterator(self.cfg, self.hass, metadata.file_path, BUF_SIZE)
+
+ async def list_backup_location(self) -> list[str]:
+ """Return a list of `*.metadata.json` files located in backup location."""
+ files = []
+ LOGGER.debug(
+ "Changing directory to: `%s`", self.cfg.runtime_data.backup_location
+ )
+ await self.sftp.chdir(self.cfg.runtime_data.backup_location)
+
+ for file in await self.sftp.listdir():
+ LOGGER.debug(
+ "Checking if file: `%s/%s` is metadata file",
+ self.cfg.runtime_data.backup_location,
+ file,
+ )
+ if file.endswith(".metadata.json"):
+ LOGGER.debug("Found metadata file: `%s`", file)
+ files.append(f"{self.cfg.runtime_data.backup_location}/{file}")
+ return files
+
+ async def open(self) -> BackupAgentClient:
+ """Return initialized `BackupAgentClient`.
+
+ This is to avoid calling `__aenter__` dunder method.
+ """
+
+ # Configure SSH Client Connection
+ try:
+ self._ssh = await connect(
+ host=self.cfg.runtime_data.host,
+ port=self.cfg.runtime_data.port,
+ options=await self.hass.async_add_executor_job(
+ get_client_options, self.cfg.runtime_data
+ ),
+ )
+ except (OSError, PermissionDenied) as e:
+ raise BackupAgentError(
+ "Failure while attempting to establish SSH connection. Please check SSH credentials and if changed, re-install the integration"
+ ) from e
+
+ # Configure SFTP Client Connection
+ try:
+ self.sftp = await self._ssh.start_sftp_client()
+ await self.sftp.chdir(self.cfg.runtime_data.backup_location)
+ except (SFTPNoSuchFile, SFTPPermissionDenied) as e:
+ raise BackupAgentError(
+ "Failed to create SFTP client. Re-installing integration might be required"
+ ) from e
+
+ return self
diff --git a/homeassistant/components/sftp_storage/config_flow.py b/homeassistant/components/sftp_storage/config_flow.py
new file mode 100644
index 00000000000..3168810edab
--- /dev/null
+++ b/homeassistant/components/sftp_storage/config_flow.py
@@ -0,0 +1,236 @@
+"""Config flow to configure the SFTP Storage integration."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+from pathlib import Path
+import shutil
+from typing import Any, cast
+
+from asyncssh import KeyImportError, SSHClientConnectionOptions, connect
+from asyncssh.misc import PermissionDenied
+from asyncssh.sftp import SFTPNoSuchFile, SFTPPermissionDenied
+import voluptuous as vol
+
+from homeassistant.components.file_upload import process_uploaded_file
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.selector import (
+ FileSelector,
+ FileSelectorConfig,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+from homeassistant.helpers.storage import STORAGE_DIR
+from homeassistant.util.ulid import ulid
+
+from . import SFTPConfigEntryData
+from .client import get_client_options
+from .const import (
+ CONF_BACKUP_LOCATION,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_PRIVATE_KEY_FILE,
+ CONF_USERNAME,
+ DEFAULT_PKEY_NAME,
+ DOMAIN,
+ LOGGER,
+)
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=22): int,
+ vol.Required(CONF_USERNAME): str,
+ vol.Optional(CONF_PASSWORD): TextSelector(
+ config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ vol.Optional(CONF_PRIVATE_KEY_FILE): FileSelector(
+ FileSelectorConfig(accept="*")
+ ),
+ vol.Required(CONF_BACKUP_LOCATION): str,
+ }
+)
+
+
+class SFTPStorageException(Exception):
+ """Base exception for SFTP Storage integration."""
+
+
+class SFTPStorageInvalidPrivateKey(SFTPStorageException):
+ """Exception raised during config flow - when user provided invalid private key file."""
+
+
+class SFTPStorageMissingPasswordOrPkey(SFTPStorageException):
+ """Exception raised during config flow - when user did not provide password or private key file."""
+
+
+class SFTPFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle an SFTP Storage config flow."""
+
+ def __init__(self) -> None:
+ """Initialize SFTP Storage Flow Handler."""
+ self._client_keys: list = []
+
+ async def _validate_auth_and_save_keyfile(
+ self, user_input: dict[str, Any]
+ ) -> dict[str, Any]:
+ """Validate authentication input and persist uploaded key file.
+
+ Ensures that at least one of password or private key is provided. When a
+ private key is supplied, the uploaded file is saved to Home Assistant's
+ config storage and `user_input[CONF_PRIVATE_KEY_FILE]` is replaced with
+ the stored path.
+
+ Returns: the possibly updated `user_input`.
+
+ Raises:
+ - SFTPStorageMissingPasswordOrPkey: Neither password nor private key provided
+ - SFTPStorageInvalidPrivateKey: The provided private key has an invalid format
+ """
+
+ # If neither password nor private key is provided, error out;
+ # we need at least one to perform authentication.
+ if not (user_input.get(CONF_PASSWORD) or user_input.get(CONF_PRIVATE_KEY_FILE)):
+ raise SFTPStorageMissingPasswordOrPkey
+
+ if key_file := user_input.get(CONF_PRIVATE_KEY_FILE):
+ client_key = await save_uploaded_pkey_file(self.hass, cast(str, key_file))
+
+ LOGGER.debug("Saved client key: %s", client_key)
+ user_input[CONF_PRIVATE_KEY_FILE] = client_key
+
+ return user_input
+
+ async def async_step_user(
+ self,
+ user_input: dict[str, Any] | None = None,
+ step_id: str = "user",
+ ) -> ConfigFlowResult:
+ """Handle a flow initiated by the user."""
+ errors: dict[str, str] = {}
+ placeholders: dict[str, str] = {}
+
+ if user_input is not None:
+ LOGGER.debug("Source: %s", self.source)
+
+ self._async_abort_entries_match(
+ {
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_PORT: user_input[CONF_PORT],
+ CONF_BACKUP_LOCATION: user_input[CONF_BACKUP_LOCATION],
+ }
+ )
+
+ try:
+ # Validate auth input and save uploaded key file if provided
+ user_input = await self._validate_auth_and_save_keyfile(user_input)
+
+ # Create a session using your credentials
+ user_config = SFTPConfigEntryData(
+ host=user_input[CONF_HOST],
+ port=user_input[CONF_PORT],
+ username=user_input[CONF_USERNAME],
+ password=user_input.get(CONF_PASSWORD),
+ private_key_file=user_input.get(CONF_PRIVATE_KEY_FILE),
+ backup_location=user_input[CONF_BACKUP_LOCATION],
+ )
+
+ placeholders["backup_location"] = user_config.backup_location
+
+ # Raises:
+ # - OSError, if host or port are not correct.
+ # - SFTPStorageInvalidPrivateKey, if private key is not valid format.
+ # - asyncssh.misc.PermissionDenied, if credentials are not correct.
+ # - SFTPStorageMissingPasswordOrPkey, if password and private key are not provided.
+ # - asyncssh.sftp.SFTPNoSuchFile, if directory does not exist.
+ # - asyncssh.sftp.SFTPPermissionDenied, if we don't have access to said directory
+ async with (
+ connect(
+ host=user_config.host,
+ port=user_config.port,
+ options=await self.hass.async_add_executor_job(
+ get_client_options, user_config
+ ),
+ ) as ssh,
+ ssh.start_sftp_client() as sftp,
+ ):
+ await sftp.chdir(user_config.backup_location)
+ await sftp.listdir()
+
+ LOGGER.debug(
+ "Will register SFTP Storage agent with user@host %s@%s",
+ user_config.host,
+ user_config.username,
+ )
+
+ except OSError as e:
+ LOGGER.exception(e)
+ placeholders["error_message"] = str(e)
+ errors["base"] = "os_error"
+ except SFTPStorageInvalidPrivateKey:
+ errors["base"] = "invalid_key"
+ except PermissionDenied as e:
+ placeholders["error_message"] = str(e)
+ errors["base"] = "permission_denied"
+ except SFTPStorageMissingPasswordOrPkey:
+ errors["base"] = "key_or_password_needed"
+ except SFTPNoSuchFile:
+ errors["base"] = "sftp_no_such_file"
+ except SFTPPermissionDenied:
+ errors["base"] = "sftp_permission_denied"
+ except Exception as e: # noqa: BLE001
+ LOGGER.exception(e)
+ placeholders["error_message"] = str(e)
+ placeholders["exception"] = type(e).__name__
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=f"{user_config.username}@{user_config.host}",
+ data=user_input,
+ )
+ finally:
+ # We remove the saved private key file if any error occurred.
+ if errors and bool(user_input.get(CONF_PRIVATE_KEY_FILE)):
+ keyfile = Path(user_input[CONF_PRIVATE_KEY_FILE])
+ keyfile.unlink(missing_ok=True)
+ with suppress(OSError):
+ keyfile.parent.rmdir()
+
+ if user_input:
+ user_input.pop(CONF_PRIVATE_KEY_FILE, None)
+
+ return self.async_show_form(
+ step_id=step_id,
+ data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input),
+ description_placeholders=placeholders,
+ errors=errors,
+ )
+
+
+async def save_uploaded_pkey_file(hass: HomeAssistant, uploaded_file_id: str) -> str:
+ """Validate the uploaded private key and move it to the storage directory.
+
+ Return a string representing a path to private key file.
+ Raises SFTPStorageInvalidPrivateKey if the file is invalid.
+ """
+
+ def _process_upload() -> str:
+ with process_uploaded_file(hass, uploaded_file_id) as file_path:
+ try:
+ # Initializing this will verify if private key is in correct format
+ SSHClientConnectionOptions(client_keys=[file_path])
+ except KeyImportError as err:
+ LOGGER.debug(err)
+ raise SFTPStorageInvalidPrivateKey from err
+
+ dest_path = Path(hass.config.path(STORAGE_DIR, DOMAIN))
+ dest_file = dest_path / f".{ulid()}_{DEFAULT_PKEY_NAME}"
+
+ # Create parent directory
+ dest_file.parent.mkdir(exist_ok=True)
+ return str(shutil.move(file_path, dest_file))
+
+ return await hass.async_add_executor_job(_process_upload)
diff --git a/homeassistant/components/sftp_storage/const.py b/homeassistant/components/sftp_storage/const.py
new file mode 100644
index 00000000000..aa582760be8
--- /dev/null
+++ b/homeassistant/components/sftp_storage/const.py
@@ -0,0 +1,27 @@
+"""Constants for the SFTP Storage integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+import logging
+from typing import Final
+
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN: Final = "sftp_storage"
+
+LOGGER = logging.getLogger(__package__)
+
+CONF_HOST: Final = "host"
+CONF_PORT: Final = "port"
+CONF_USERNAME: Final = "username"
+CONF_PASSWORD: Final = "password"
+CONF_PRIVATE_KEY_FILE: Final = "private_key_file"
+CONF_BACKUP_LOCATION: Final = "backup_location"
+
+BUF_SIZE = 2**20 * 4 # 4MB
+
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
+DEFAULT_PKEY_NAME: str = "sftp_storage_pkey"
diff --git a/homeassistant/components/sftp_storage/manifest.json b/homeassistant/components/sftp_storage/manifest.json
new file mode 100644
index 00000000000..c206bd13811
--- /dev/null
+++ b/homeassistant/components/sftp_storage/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "sftp_storage",
+ "name": "SFTP Storage",
+ "after_dependencies": ["backup"],
+ "codeowners": ["@maretodoric"],
+ "config_flow": true,
+ "dependencies": ["file_upload"],
+ "documentation": "https://www.home-assistant.io/integrations/sftp_storage",
+ "integration_type": "service",
+ "iot_class": "local_polling",
+ "quality_scale": "silver",
+ "requirements": ["asyncssh==2.21.0"]
+}
diff --git a/homeassistant/components/sftp_storage/quality_scale.yaml b/homeassistant/components/sftp_storage/quality_scale.yaml
new file mode 100644
index 00000000000..1d34426be02
--- /dev/null
+++ b/homeassistant/components/sftp_storage/quality_scale.yaml
@@ -0,0 +1,140 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: No actions.
+ appropriate-polling:
+ status: exempt
+ comment: No polling.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: No actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: No entities.
+ entity-unique-id:
+ status: exempt
+ comment: No entities.
+ has-entity-name:
+ status: exempt
+ comment: No entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No configuration options.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: No entities.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: No entities.
+ parallel-updates:
+ status: exempt
+ comment: No actions and no entities.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This backup storage integration uses static SFTP credentials that do not expire
+ or require token refresh. Authentication failures indicate configuration issues
+ that should be resolved by reconfiguring the integration.
+ test-coverage: done
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ diagnostics:
+ status: exempt
+ comment: |
+ There is no data to diagnose.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ docs-data-update:
+ status: exempt
+ comment: |
+ This integration does not poll or push.
+ docs-examples:
+ status: exempt
+ comment: |
+ This integration only serves backup.
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This integration is a cloud service.
+ docs-supported-functions:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ entity-category:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ This backup storage integration's configuration consists of static SFTP
+ connection parameters (host, port, credentials, backup path). Changes to
+ these parameters effectively create a connection to a different backup
+ location, which should be configured as a separate integration instance.
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration provides backup storage functionality only. Connection
+ failures are handled through config entry setup errors and do not require
+ persistent repair issues. Users can resolve authentication or connectivity
+ problems by reconfiguring the integration through the config flow.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/sftp_storage/strings.json b/homeassistant/components/sftp_storage/strings.json
new file mode 100644
index 00000000000..da328bfd854
--- /dev/null
+++ b/homeassistant/components/sftp_storage/strings.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Set up SFTP Storage",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "private_key_file": "Private key file",
+ "backup_location": "Remote path"
+ },
+ "data_description": {
+ "host": "Hostname or IP address of SSH/SFTP server to connect to.",
+ "port": "Port of your SSH/SFTP server. This is usually 22.",
+ "username": "Username to authenticate with.",
+ "password": "Password to authenticate with. Provide this or private key file.",
+ "private_key_file": "Upload private key file used for authentication. Provide this or password.",
+ "backup_location": "Remote path where to upload backups."
+ }
+ }
+ },
+ "error": {
+ "invalid_key": "Invalid key uploaded. Please make sure key corresponds to valid SSH key algorithm.",
+ "key_or_password_needed": "Please configure password or private key file location for SFTP Storage.",
+ "os_error": "{error_message}. Please check if host and/or port are correct.",
+ "permission_denied": "{error_message}",
+ "sftp_no_such_file": "Could not check directory {backup_location}. Make sure directory exists.",
+ "sftp_permission_denied": "Permission denied for directory {backup_location}",
+ "unknown": "Unexpected exception ({exception}) occurred during config flow. {error_message}"
+ },
+ "abort": {
+ "already_configured": "Integration already configured. Host with same address, port and backup location already exists."
+ }
+ }
+}
diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py
index e560bb77b57..b87f52ba7b1 100644
--- a/homeassistant/components/sharkiq/__init__.py
+++ b/homeassistant/components/sharkiq/__init__.py
@@ -3,6 +3,7 @@
import asyncio
from contextlib import suppress
+import aiohttp
from sharkiq import (
AylaApi,
SharkIqAuthError,
@@ -15,7 +16,7 @@ from homeassistant import exceptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
API_TIMEOUT,
@@ -56,10 +57,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
data={**config_entry.data, CONF_REGION: SHARKIQ_REGION_DEFAULT},
)
+ new_websession = async_create_clientsession(
+ hass,
+ cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False),
+ )
+
ayla_api = get_ayla_api(
username=config_entry.data[CONF_USERNAME],
password=config_entry.data[CONF_PASSWORD],
- websession=async_get_clientsession(hass),
+ websession=new_websession,
europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE),
)
@@ -94,7 +100,7 @@ async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator):
await coordinator.ayla_api.async_sign_out()
-async def async_update_options(hass, config_entry):
+async def async_update_options(hass: HomeAssistant, config_entry):
"""Update options."""
await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py
index 87367fcf093..7174c634787 100644
--- a/homeassistant/components/sharkiq/config_flow.py
+++ b/homeassistant/components/sharkiq/config_flow.py
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
DOMAIN,
@@ -44,15 +44,19 @@ async def _validate_input(
hass: HomeAssistant, data: Mapping[str, Any]
) -> dict[str, str]:
"""Validate the user input allows us to connect."""
+ new_websession = async_create_clientsession(
+ hass,
+ cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False),
+ )
ayla_api = get_ayla_api(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
- websession=async_get_clientsession(hass),
+ websession=new_websession,
europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE),
)
try:
- async with asyncio.timeout(10):
+ async with asyncio.timeout(15):
LOGGER.debug("Initialize connection to Ayla networks API")
await ayla_api.async_sign_in()
except (TimeoutError, aiohttp.ClientError, TypeError) as error:
diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json
index c29fc582462..793f65483ea 100644
--- a/homeassistant/components/sharkiq/manifest.json
+++ b/homeassistant/components/sharkiq/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",
"loggers": ["sharkiq"],
- "requirements": ["sharkiq==1.1.1"]
+ "requirements": ["sharkiq==1.4.0"]
}
diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py
index 5582ab488df..c2df1ed4cb2 100644
--- a/homeassistant/components/shelly/__init__.py
+++ b/homeassistant/components/shelly/__init__.py
@@ -59,6 +59,7 @@ from .coordinator import (
from .repairs import (
async_manage_ble_scanner_firmware_unsupported_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
+ async_manage_wall_display_firmware_unsupported_issue,
)
from .utils import (
async_create_issue_unsupported_firmware,
@@ -67,6 +68,7 @@ from .utils import (
get_http_port,
get_rpc_scripts_event_types,
get_ws_context,
+ remove_empty_sub_devices,
remove_stale_blu_trv_devices,
)
@@ -223,6 +225,7 @@ async def _async_setup_block_entry(
await hass.config_entries.async_forward_entry_setups(
entry, runtime_data.platforms
)
+ remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None
or device_entry is None
@@ -326,6 +329,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
await hass.config_entries.async_forward_entry_setups(
entry, runtime_data.platforms
)
+ async_manage_wall_display_firmware_unsupported_issue(hass, entry)
async_manage_ble_scanner_firmware_unsupported_issue(
hass,
entry,
@@ -334,6 +338,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
hass,
entry,
)
+ remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None
or device_entry is None
diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py
index e7d7b46b322..3cce2f0183f 100644
--- a/homeassistant/components/shelly/binary_sensor.py
+++ b/homeassistant/components/shelly/binary_sensor.py
@@ -37,9 +37,9 @@ from .utils import (
async_remove_orphaned_entities,
get_blu_trv_device_info,
get_device_entry_gen,
- get_virtual_component_ids,
is_block_momentary_input,
is_rpc_momentary_input,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -73,6 +73,17 @@ class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
return bool(self.attribute_value)
+class RpcPresenceBinarySensor(RpcBinarySensor):
+ """Represent a RPC binary sensor entity for presence component."""
+
+ @property
+ def available(self) -> bool:
+ """Available."""
+ available = super().available
+
+ return available and self.coordinator.device.config[self.key]["enable"]
+
+
class RpcBluTrvBinarySensor(RpcBinarySensor):
"""Represent a RPC BluTrv binary sensor."""
@@ -87,8 +98,9 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
super().__init__(coordinator, key, attribute, description)
ble_addr: str = coordinator.device.config[key]["addr"]
+ fw_ver = coordinator.device.status[key].get("fw_ver")
self._attr_device_info = get_blu_trv_device_info(
- coordinator.device.config[key], ble_addr, coordinator.mac
+ coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver
)
@@ -132,8 +144,6 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
device_class=BinarySensorDeviceClass.GAS,
translation_key="gas",
value=lambda value: value in ["mild", "heavy"],
- # Deprecated, remove in 2025.10
- extra_state_attributes=lambda block: {"detected": block.gas},
),
("sensor", "smoke"): BlockBinarySensorDescription(
key="sensor|smoke", name="Smoke", device_class=BinarySensorDeviceClass.SMOKE
@@ -263,6 +273,9 @@ RPC_SENSORS: Final = {
"boolean": RpcBinarySensorDescription(
key="boolean",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, BINARY_SENSOR_PLATFORM
+ ),
),
"calibration": RpcBinarySensorDescription(
key="blutrv",
@@ -285,6 +298,32 @@ RPC_SENSORS: Final = {
name="Mute",
entity_category=EntityCategory.DIAGNOSTIC,
),
+ "flood_cable_unplugged": RpcBinarySensorDescription(
+ key="flood",
+ sub_key="errors",
+ value=lambda status, _: False
+ if status is None
+ else "cable_unplugged" in status,
+ name="Cable unplugged",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ supported=lambda status: status.get("alarm") is not None,
+ ),
+ "presence_num_objects": RpcBinarySensorDescription(
+ key="presence",
+ sub_key="num_objects",
+ value=lambda status, _: bool(status),
+ name="Occupancy",
+ device_class=BinarySensorDeviceClass.OCCUPANCY,
+ entity_class=RpcPresenceBinarySensor,
+ ),
+ "presencezone_state": RpcBinarySensorDescription(
+ key="presencezone",
+ sub_key="value",
+ name="Occupancy",
+ device_class=BinarySensorDeviceClass.OCCUPANCY,
+ entity_class=RpcPresenceBinarySensor,
+ ),
}
@@ -311,18 +350,12 @@ async def async_setup_entry(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor
)
- # the user can remove virtual components from the device configuration, so
- # we need to remove orphaned entities
- virtual_binary_sensor_ids = get_virtual_component_ids(
- coordinator.device.config, BINARY_SENSOR_PLATFORM
- )
async_remove_orphaned_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BINARY_SENSOR_PLATFORM,
- virtual_binary_sensor_ids,
- "boolean",
+ coordinator.device.status,
)
return
diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py
index 209fa4af54a..fbc46160f1c 100644
--- a/homeassistant/components/shelly/button.py
+++ b/homeassistant/components/shelly/button.py
@@ -9,8 +9,10 @@ from typing import TYPE_CHECKING, Any, Final
from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
+from aioshelly.rpc_device import RpcDevice
from homeassistant.components.button import (
+ DOMAIN as BUTTON_PLATFORM,
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
@@ -21,12 +23,19 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from homeassistant.util import slugify
from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import get_entity_block_device_info, get_entity_rpc_device_info
-from .utils import get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids
+from .utils import (
+ async_remove_orphaned_entities,
+ format_ble_addr,
+ get_blu_trv_device_info,
+ get_device_entry_gen,
+ get_rpc_entity_name,
+ get_rpc_key_ids,
+ get_virtual_component_ids,
+)
PARALLEL_UPDATES = 0
@@ -87,6 +96,13 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
),
]
+VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [
+ ShellyButtonDescription[ShellyRpcCoordinator](
+ key="button",
+ press_action="single_push",
+ )
+]
+
@callback
def async_migrate_unique_ids(
@@ -97,12 +113,10 @@ def async_migrate_unique_ids(
if not entity_entry.entity_id.startswith("button"):
return None
- device_name = slugify(coordinator.device.name)
-
for key in ("reboot", "self_test", "mute", "unmute"):
- old_unique_id = f"{device_name}_{key}"
+ old_unique_id = f"{coordinator.mac}_{key}"
if entity_entry.unique_id == old_unique_id:
- new_unique_id = f"{coordinator.mac}_{key}"
+ new_unique_id = f"{coordinator.mac}-{key}"
LOGGER.debug(
"Migrating unique_id for %s entity from [%s] to [%s]",
entity_entry.entity_id,
@@ -115,6 +129,26 @@ def async_migrate_unique_ids(
)
}
+ if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
+ assert isinstance(coordinator.device, RpcDevice)
+ for _id in blutrv_key_ids:
+ key = f"{BLU_TRV_IDENTIFIER}:{_id}"
+ ble_addr: str = coordinator.device.config[key]["addr"]
+ old_unique_id = f"{ble_addr}_calibrate"
+ if entity_entry.unique_id == old_unique_id:
+ new_unique_id = f"{format_ble_addr(ble_addr)}-{key}-calibrate"
+ LOGGER.debug(
+ "Migrating unique_id for %s entity from [%s] to [%s]",
+ entity_entry.entity_id,
+ old_unique_id,
+ new_unique_id,
+ )
+ return {
+ "new_unique_id": entity_entry.unique_id.replace(
+ old_unique_id, new_unique_id
+ )
+ }
+
return None
@@ -138,7 +172,7 @@ async def async_setup_entry(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
- entities: list[ShellyButton | ShellyBluTrvButton] = []
+ entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = []
entities.extend(
ShellyButton(coordinator, button)
@@ -146,10 +180,20 @@ async def async_setup_entry(
if button.supported(coordinator)
)
- if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
- if TYPE_CHECKING:
- assert isinstance(coordinator, ShellyRpcCoordinator)
+ if not isinstance(coordinator, ShellyRpcCoordinator):
+ async_add_entities(entities)
+ return
+ # add virtual buttons
+ if virtual_button_ids := get_rpc_key_ids(coordinator.device.status, "button"):
+ entities.extend(
+ ShellyVirtualButton(coordinator, button, id_)
+ for id_ in virtual_button_ids
+ for button in VIRTUAL_BUTTONS
+ )
+
+ # add BLU TRV buttons
+ if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
entities.extend(
ShellyBluTrvButton(coordinator, button, id_)
for id_ in blutrv_key_ids
@@ -159,6 +203,19 @@ async def async_setup_entry(
async_add_entities(entities)
+ # the user can remove virtual components from the device configuration, so
+ # we need to remove orphaned entities
+ virtual_button_component_ids = get_virtual_component_ids(
+ coordinator.device.config, BUTTON_PLATFORM
+ )
+ async_remove_orphaned_entities(
+ hass,
+ config_entry.entry_id,
+ coordinator.mac,
+ BUTTON_PLATFORM,
+ virtual_button_component_ids,
+ )
+
class ShellyBaseButton(
CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity
@@ -226,7 +283,7 @@ class ShellyButton(ShellyBaseButton):
"""Initialize Shelly button."""
super().__init__(coordinator, description)
- self._attr_unique_id = f"{coordinator.mac}_{description.key}"
+ self._attr_unique_id = f"{coordinator.mac}-{description.key}"
if isinstance(coordinator, ShellyBlockCoordinator):
self._attr_device_info = get_entity_block_device_info(coordinator)
else:
@@ -254,11 +311,14 @@ class ShellyBluTrvButton(ShellyBaseButton):
"""Initialize."""
super().__init__(coordinator, description)
- config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]
+ key = f"{BLU_TRV_IDENTIFIER}:{id_}"
+ config = coordinator.device.config[key]
ble_addr: str = config["addr"]
- self._attr_unique_id = f"{ble_addr}_{description.key}"
+ fw_ver = coordinator.device.status[key].get("fw_ver")
+
+ self._attr_unique_id = f"{format_ble_addr(ble_addr)}-{key}-{description.key}"
self._attr_device_info = get_blu_trv_device_info(
- config, ble_addr, coordinator.mac
+ config, ble_addr, coordinator.mac, fw_ver
)
self._id = id_
@@ -270,3 +330,32 @@ class ShellyBluTrvButton(ShellyBaseButton):
assert method is not None
await method(self._id)
+
+
+class ShellyVirtualButton(ShellyBaseButton):
+ """Defines a Shelly virtual component button."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ description: ShellyButtonDescription,
+ _id: int,
+ ) -> None:
+ """Initialize Shelly virtual component button."""
+ super().__init__(coordinator, description)
+
+ self._attr_unique_id = f"{coordinator.mac}-{description.key}:{_id}"
+ self._attr_device_info = get_entity_rpc_device_info(coordinator)
+ self._attr_name = get_rpc_entity_name(
+ coordinator.device, f"{description.key}:{_id}"
+ )
+ self._id = _id
+
+ async def _press_method(self) -> None:
+ """Press method."""
+ if TYPE_CHECKING:
+ assert isinstance(self.coordinator, ShellyRpcCoordinator)
+
+ await self.coordinator.device.button_trigger(
+ self._id, self.entity_description.press_action
+ )
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index 3a495c9f4ac..e0cffbc7f17 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict, dataclass
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from aioshelly.block_device import Block
from aioshelly.const import BLU_TRV_IDENTIFIER, RPC_GENERATIONS
@@ -14,6 +14,7 @@ from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
PRESET_NONE,
ClimateEntity,
+ ClimateEntityDescription,
ClimateEntityFeature,
HVACAction,
HVACMode,
@@ -33,23 +34,235 @@ from .const import (
BLU_TRV_TEMPERATURE_SETTINGS,
DOMAIN,
LOGGER,
+ MODEL_LINKEDGO_ST802_THERMOSTAT,
+ MODEL_LINKEDGO_ST1820_THERMOSTAT,
NOT_CALIBRATED_ISSUE_ID,
RPC_THERMOSTAT_SETTINGS,
SHTRV_01_TEMPERATURE_SETTINGS,
)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
-from .entity import ShellyRpcEntity, get_entity_block_device_info, rpc_call
+from .entity import (
+ RpcEntityDescription,
+ ShellyRpcAttributeEntity,
+ ShellyRpcEntity,
+ async_setup_entry_rpc,
+ get_entity_block_device_info,
+ rpc_call,
+)
from .utils import (
async_remove_shelly_entity,
get_block_entity_name,
get_blu_trv_device_info,
get_device_entry_gen,
+ get_rpc_key_by_role,
get_rpc_key_ids,
+ id_from_key,
is_rpc_thermostat_internal_actuator,
)
PARALLEL_UPDATES = 0
+THERMOSTAT_TO_HA_MODE = {
+ "cool": HVACMode.COOL,
+ "dry": HVACMode.DRY,
+ "heat": HVACMode.HEAT,
+ "ventilation": HVACMode.FAN_ONLY,
+}
+
+HA_TO_THERMOSTAT_MODE = {value: key for key, value in THERMOSTAT_TO_HA_MODE.items()}
+
+PRESET_FROST_PROTECTION = "frost_protection"
+
+
+@dataclass(kw_only=True, frozen=True)
+class RpcClimateDescription(RpcEntityDescription, ClimateEntityDescription):
+ """Class to describe a RPC climate."""
+
+
+class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
+ """Entity that controls a LINKEDGO Thermostat on RPC based Shelly devices."""
+
+ entity_description: RpcClimateDescription
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_translation_key = "thermostat"
+ _id: int
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcEntityDescription,
+ ) -> None:
+ """Initialize RPC LINKEDGO Thermostat."""
+ super().__init__(coordinator, key, attribute, description)
+ self._attr_name = None # Main device entity
+
+ self._attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.TURN_ON
+ )
+
+ config = coordinator.device.config
+ self._status = coordinator.device.status
+
+ self._attr_min_temp = config[key]["min"]
+ self._attr_max_temp = config[key]["max"]
+ self._attr_target_temperature_step = config[key]["meta"]["ui"]["step"]
+
+ self._current_humidity_key = get_rpc_key_by_role(config, "current_humidity")
+ self._current_temperature_key = get_rpc_key_by_role(
+ config, "current_temperature"
+ )
+ self._thermostat_enable_key = get_rpc_key_by_role(config, "enable")
+
+ self._target_humidity_key = get_rpc_key_by_role(config, "target_humidity")
+ if self._target_humidity_key:
+ self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
+ self._attr_min_humidity = config[self._target_humidity_key]["min"]
+ self._attr_max_humidity = config[self._target_humidity_key]["max"]
+
+ self._anti_freeze_key = get_rpc_key_by_role(config, "anti_freeze")
+ if self._anti_freeze_key:
+ self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
+ self._attr_preset_modes = [PRESET_NONE, PRESET_FROST_PROTECTION]
+
+ self._fan_speed_key = get_rpc_key_by_role(config, "fan_speed")
+ if self._fan_speed_key:
+ self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
+ self._attr_fan_modes = config[self._fan_speed_key]["options"]
+
+ # ST1820 only supports HEAT and OFF
+ self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
+ # ST802 supports multiple working modes
+ self._working_mode_key = get_rpc_key_by_role(config, "working_mode")
+ if self._working_mode_key:
+ modes = config[self._working_mode_key]["options"]
+ self._attr_hvac_modes = [HVACMode.OFF] + [
+ THERMOSTAT_TO_HA_MODE[mode] for mode in modes
+ ]
+
+ @property
+ def current_humidity(self) -> float | None:
+ """Return the current humidity."""
+ if TYPE_CHECKING:
+ assert self._current_humidity_key is not None
+
+ return cast(float, self._status[self._current_humidity_key]["value"])
+
+ @property
+ def target_humidity(self) -> float | None:
+ """Return the humidity we try to reach."""
+ if TYPE_CHECKING:
+ assert self._target_humidity_key is not None
+
+ return cast(float, self._status[self._target_humidity_key]["value"])
+
+ @property
+ def hvac_mode(self) -> HVACMode | None:
+ """Return hvac operation ie. heat, cool mode."""
+ if TYPE_CHECKING:
+ assert self._thermostat_enable_key is not None
+
+ if not self._status[self._thermostat_enable_key]["value"]:
+ return HVACMode.OFF
+
+ if self._working_mode_key is not None:
+ working_mode = self._status[self._working_mode_key]["value"]
+ return THERMOSTAT_TO_HA_MODE[working_mode]
+
+ return HVACMode.HEAT # ST1820
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ if TYPE_CHECKING:
+ assert self._current_temperature_key is not None
+
+ return cast(float, self._status[self._current_temperature_key]["value"])
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Return the temperature we try to reach."""
+ return cast(float, self.attribute_value)
+
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the current preset mode."""
+ if TYPE_CHECKING:
+ assert self._anti_freeze_key is not None
+
+ if self._status[self._anti_freeze_key]["value"]:
+ return PRESET_FROST_PROTECTION
+
+ return PRESET_NONE
+
+ @property
+ def fan_mode(self) -> str | None:
+ """Return the fan setting."""
+ if TYPE_CHECKING:
+ assert self._fan_speed_key is not None
+
+ return cast(str, self._status[self._fan_speed_key]["value"])
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ await self.coordinator.device.number_set(self._id, kwargs[ATTR_TEMPERATURE])
+
+ async def async_set_humidity(self, humidity: int) -> None:
+ """Set new target humidity."""
+ assert self._target_humidity_key is not None
+
+ await self.coordinator.device.number_set(
+ id_from_key(self._target_humidity_key), humidity
+ )
+
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
+ """Set new target fan mode."""
+ if TYPE_CHECKING:
+ assert self._fan_speed_key is not None
+
+ await self.coordinator.device.enum_set(
+ id_from_key(self._fan_speed_key), fan_mode
+ )
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set new target hvac mode."""
+ if TYPE_CHECKING:
+ assert self._thermostat_enable_key is not None
+
+ await self.coordinator.device.boolean_set(
+ id_from_key(self._thermostat_enable_key), hvac_mode != HVACMode.OFF
+ )
+
+ if self._working_mode_key is None or hvac_mode == HVACMode.OFF:
+ return
+
+ await self.coordinator.device.enum_set(
+ id_from_key(self._working_mode_key),
+ HA_TO_THERMOSTAT_MODE[hvac_mode],
+ )
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new preset mode."""
+ if TYPE_CHECKING:
+ assert self._anti_freeze_key is not None
+
+ await self.coordinator.device.boolean_set(
+ id_from_key(self._anti_freeze_key), preset_mode == PRESET_FROST_PROTECTION
+ )
+
+
+RPC_LINKEDGO_THERMOSTAT: dict[str, RpcClimateDescription] = {
+ "linkedgo_thermostat_climate": RpcClimateDescription(
+ key="number",
+ sub_key="value",
+ role="target_temperature",
+ models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
+ ),
+}
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -150,6 +363,14 @@ def async_setup_rpc_entry(
if blutrv_key_ids:
async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids)
+ async_setup_entry_rpc(
+ hass,
+ config_entry,
+ async_add_entities,
+ RPC_LINKEDGO_THERMOSTAT,
+ RpcLinkedgoThermostatClimate,
+ )
+
@dataclass
class ShellyClimateExtraStoredData(ExtraStoredData):
@@ -333,8 +554,7 @@ class BlockSleepingClimate(
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if (current_temp := kwargs.get(ATTR_TEMPERATURE)) is None:
- return
+ target_temp = kwargs[ATTR_TEMPERATURE]
# Shelly TRV accepts target_t in Fahrenheit or Celsius, but you must
# send the units that the device expects
@@ -344,13 +564,13 @@ class BlockSleepingClimate(
]
LOGGER.debug("Themostat settings: %s", therm)
if therm.get("target_t", {}).get("units", "C") == "F":
- current_temp = TemperatureConverter.convert(
- cast(float, current_temp),
+ target_temp = TemperatureConverter.convert(
+ cast(float, target_temp),
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
- await self.set_state_full_path(target_t_enabled=1, target_t=f"{current_temp}")
+ await self.set_state_full_path(target_t_enabled=1, target_t=f"{target_temp}")
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
@@ -367,9 +587,6 @@ class BlockSleepingClimate(
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
- if not self._preset_modes:
- return
-
preset_index = self._preset_modes.index(preset_mode)
if preset_index == 0:
@@ -523,12 +740,9 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None:
- return
-
await self.call_rpc(
"Thermostat.SetConfig",
- {"config": {"id": self._id, "target_C": target_temp}},
+ {"config": {"id": self._id, "target_C": kwargs[ATTR_TEMPERATURE]}},
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -557,11 +771,12 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity):
super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}")
self._id = id_
- self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]
+ self._config = coordinator.device.config[self.key]
ble_addr: str = self._config["addr"]
self._attr_unique_id = f"{ble_addr}-{self.key}"
+ fw_ver = coordinator.device.status[self.key].get("fw_ver")
self._attr_device_info = get_blu_trv_device_info(
- self._config, ble_addr, self.coordinator.mac
+ self._config, ble_addr, self.coordinator.mac, fw_ver
)
@property
@@ -588,9 +803,6 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity):
@rpc_call
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None:
- return
-
await self.coordinator.device.blu_trv_set_target_temperature(
- self._id, target_temp
+ self._id, kwargs[ATTR_TEMPERATURE]
)
diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py
index 60fc5b03d13..d99be1b0eb3 100644
--- a/homeassistant/components/shelly/const.py
+++ b/homeassistant/components/shelly/const.py
@@ -26,10 +26,11 @@ from aioshelly.const import (
MODEL_VINTAGE_V2,
MODEL_WALL_DISPLAY,
MODEL_WALL_DISPLAY_X2,
+ MODEL_WALL_DISPLAY_XL,
)
from homeassistant.components.number import NumberMode
-from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.const import UnitOfVolumeFlowRate
DOMAIN: Final = "shelly"
@@ -44,6 +45,9 @@ BLOCK_MAX_TRANSITION_TIME_MS: Final = 5000
# min RPC light transition time in seconds (max=10800, limited by light entity to 6553)
RPC_MIN_TRANSITION_TIME_SEC = 0.5
+# time in seconds between two cover state updates when moving
+RPC_COVER_UPDATE_TIME_SEC = 1.0
+
RGBW_MODELS: Final = (
MODEL_BULB,
MODEL_RGBW2,
@@ -228,6 +232,7 @@ class BLEScannerMode(StrEnum):
BLE_SCANNER_MIN_FIRMWARE = "1.5.1"
+WALL_DISPLAY_MIN_FIRMWARE = "2.3.0"
MAX_PUSH_UPDATE_FAILURES = 5
PUSH_UPDATE_ISSUE_ID = "push_update_{unique}"
@@ -240,6 +245,9 @@ BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{u
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
"outbound_websocket_incorrectly_enabled_{unique}"
)
+WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID = (
+ "wall_display_firmware_unsupported_{unique}"
+)
GAS_VALVE_OPEN_STATES = ("opening", "opened")
@@ -254,6 +262,7 @@ GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased"
DEVICES_WITHOUT_FIRMWARE_CHANGELOG = (
MODEL_WALL_DISPLAY,
MODEL_WALL_DISPLAY_X2,
+ MODEL_WALL_DISPLAY_XL,
MODEL_MOTION,
MODEL_MOTION_2,
MODEL_VALVE,
@@ -261,9 +270,10 @@ DEVICES_WITHOUT_FIRMWARE_CHANGELOG = (
CONF_GEN = "gen"
-VIRTUAL_COMPONENTS = ("boolean", "enum", "input", "number", "text")
+VIRTUAL_COMPONENTS = ("boolean", "button", "enum", "input", "number", "text")
VIRTUAL_COMPONENTS_MAP = {
"binary_sensor": {"types": ["boolean"], "modes": ["label"]},
+ "button": {"types": ["button"], "modes": ["button"]},
"number": {"types": ["number"], "modes": ["field", "slider"]},
"select": {"types": ["enum"], "modes": ["dropdown"]},
"sensor": {"types": ["enum", "number", "text"], "modes": ["label"]},
@@ -281,9 +291,10 @@ API_WS_URL = "/api/shelly/ws"
COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+")
-ROLE_TO_DEVICE_CLASS_MAP = {
- "current_humidity": SensorDeviceClass.HUMIDITY,
- "current_temperature": SensorDeviceClass.TEMPERATURE,
+# Mapping for units that require conversion to a Home Assistant recognized unit
+# e.g. "m3/min" to "m³/min"
+DEVICE_UNIT_MAP = {
+ "m3/min": UnitOfVolumeFlowRate.CUBIC_METERS_PER_MINUTE,
}
# We want to check only the first 5 KB of the script if it contains emitEvent()
@@ -291,3 +302,9 @@ ROLE_TO_DEVICE_CLASS_MAP = {
MAX_SCRIPT_SIZE = 5120
All_LIGHT_TYPES = ("cct", "light", "rgb", "rgbw")
+
+# Shelly-X specific models
+MODEL_NEO_WATER_VALVE = "NeoWaterValve"
+MODEL_FRANKEVER_WATER_VALVE = "WaterValve"
+MODEL_LINKEDGO_ST802_THERMOSTAT = "ST-802"
+MODEL_LINKEDGO_ST1820_THERMOSTAT = "ST1820"
diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py
index eba6b846fe4..69c2d5c33de 100644
--- a/homeassistant/components/shelly/coordinator.py
+++ b/homeassistant/components/shelly/coordinator.py
@@ -631,6 +631,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
"""Handle device events."""
events: list[dict[str, Any]] = event_data["events"]
for event in events:
+ # filter out button events as they are triggered by button entities
+ component = event.get("component")
+ if component is not None and component.startswith("button"):
+ continue
+
event_type = event.get("event")
if event_type is None:
continue
diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py
index d603636644b..bdca7cee921 100644
--- a/homeassistant/components/shelly/cover.py
+++ b/homeassistant/components/shelly/cover.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from typing import Any, cast
from aioshelly.block_device import Block
@@ -17,6 +18,7 @@ from homeassistant.components.cover import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import RPC_COVER_UPDATE_TIME_SEC
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import ShellyBlockEntity, ShellyRpcEntity
from .utils import get_device_entry_gen, get_rpc_key_ids
@@ -158,6 +160,7 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity):
"""Initialize rpc cover."""
super().__init__(coordinator, f"cover:{id_}")
self._id = id_
+ self._update_task: asyncio.Task | None = None
if self.status["pos_control"]:
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
if coordinator.device.config[f"cover:{id_}"].get("slat", {}).get("enable"):
@@ -199,6 +202,33 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity):
"""Return if the cover is opening."""
return cast(bool, self.status["state"] == "opening")
+ def launch_update_task(self) -> None:
+ """Launch the update position task if needed."""
+ if not self._update_task or self._update_task.done():
+ self._update_task = (
+ self.coordinator.config_entry.async_create_background_task(
+ self.hass,
+ self.update_position(),
+ f"Shelly cover update [{self._id} - {self.name}]",
+ )
+ )
+
+ async def update_position(self) -> None:
+ """Update the cover position every second."""
+ try:
+ while self.is_closing or self.is_opening:
+ await self.coordinator.device.update_status()
+ self.async_write_ha_state()
+ await asyncio.sleep(RPC_COVER_UPDATE_TIME_SEC)
+ finally:
+ self._update_task = None
+
+ def _update_callback(self) -> None:
+ """Handle device update. Use a task when opening/closing is in progress."""
+ super()._update_callback()
+ if self.is_closing or self.is_opening:
+ self.launch_update_task()
+
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
await self.call_rpc("Cover.Close", {"id": self._id})
diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py
index 97946ddd8f3..0e4a2b00742 100644
--- a/homeassistant/components/shelly/entity.py
+++ b/homeassistant/components/shelly/entity.py
@@ -186,6 +186,14 @@ def async_setup_rpc_attribute_entities(
for key in key_instances:
# Filter non-existing sensors
+ if description.models and coordinator.model not in description.models:
+ continue
+
+ if description.role and description.role != coordinator.device.config[
+ key
+ ].get("role", "generic"):
+ continue
+
if description.sub_key not in coordinator.device.status[
key
] and not description.supported(coordinator.device.status[key]):
@@ -231,7 +239,7 @@ def async_restore_rpc_attribute_entities(
sensors: Mapping[str, RpcEntityDescription],
sensor_class: Callable,
) -> None:
- """Restore block attributes entities."""
+ """Restore RPC attributes entities."""
entities = []
ent_reg = er.async_get(hass)
@@ -290,7 +298,6 @@ class BlockEntityDescription(EntityDescription):
available: Callable[[Block], bool] | None = None
# Callable (settings, block), return true if entity should be removed
removal_condition: Callable[[dict, Block], bool] | None = None
- extra_state_attributes: Callable[[Block], dict | None] | None = None
@dataclass(frozen=True, kw_only=True)
@@ -311,6 +318,8 @@ class RpcEntityDescription(EntityDescription):
unit: Callable[[dict], str | None] | None = None
options_fn: Callable[[dict], list[str]] | None = None
entity_class: Callable | None = None
+ role: str | None = None
+ models: set[str] | None = None
@dataclass(frozen=True)
@@ -438,19 +447,11 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
self.async_write_ha_state()
@rpc_call
- async def call_rpc(
- self, method: str, params: Any, timeout: float | None = None
- ) -> Any:
+ async def call_rpc(self, method: str, params: Any) -> Any:
"""Call RPC method."""
LOGGER.debug(
- "Call RPC for entity %s, method: %s, params: %s, timeout: %s",
- self.name,
- method,
- params,
- timeout,
+ "Call RPC for entity %s, method: %s, params: %s", self.name, method, params
)
- if timeout:
- return await self.coordinator.device.call_rpc(method, params, timeout)
return await self.coordinator.device.call_rpc(method, params)
@@ -494,14 +495,6 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
return self.entity_description.available(self.block)
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- if self.entity_description.extra_state_attributes is None:
- return None
-
- return self.entity_description.extra_state_attributes(self.block)
-
class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
"""Class to load info from REST."""
diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json
index 08b269a73c5..dfc5cbc2e68 100644
--- a/homeassistant/components/shelly/icons.json
+++ b/homeassistant/components/shelly/icons.json
@@ -20,6 +20,12 @@
}
},
"sensor": {
+ "charger_state": {
+ "default": "mdi:ev-station"
+ },
+ "detected_objects": {
+ "default": "mdi:account-group"
+ },
"gas_concentration": {
"default": "mdi:gauge"
},
@@ -43,6 +49,9 @@
},
"valve_status": {
"default": "mdi:valve"
+ },
+ "illuminance_level": {
+ "default": "mdi:brightness-5"
}
},
"switch": {
diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py
index f5cffe37d5a..83e2544c084 100644
--- a/homeassistant/components/shelly/light.py
+++ b/homeassistant/components/shelly/light.py
@@ -2,7 +2,8 @@
from __future__ import annotations
-from typing import Any, cast
+from dataclasses import dataclass
+from typing import Any, Final, cast
from aioshelly.block_device import Block
from aioshelly.const import MODEL_BULB, RPC_GENERATIONS
@@ -17,6 +18,7 @@ from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
ColorMode,
LightEntity,
+ LightEntityDescription,
LightEntityFeature,
brightness_supported,
)
@@ -37,13 +39,17 @@ from .const import (
STANDARD_RGB_EFFECTS,
)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
-from .entity import ShellyBlockEntity, ShellyRpcEntity
+from .entity import (
+ RpcEntityDescription,
+ ShellyBlockEntity,
+ ShellyRpcAttributeEntity,
+ async_setup_entry_rpc,
+)
from .utils import (
async_remove_orphaned_entities,
async_remove_shelly_entity,
brightness_to_percentage,
get_device_entry_gen,
- get_rpc_key_ids,
is_block_channel_type_light,
is_rpc_channel_type_light,
percentage_to_brightness,
@@ -94,53 +100,6 @@ def async_setup_block_entry(
async_add_entities(BlockShellyLight(coordinator, block) for block in blocks)
-@callback
-def async_setup_rpc_entry(
- hass: HomeAssistant,
- config_entry: ShellyConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Set up entities for RPC device."""
- coordinator = config_entry.runtime_data.rpc
- assert coordinator
- switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch")
-
- switch_ids = []
- for id_ in switch_key_ids:
- if not is_rpc_channel_type_light(coordinator.device.config, id_):
- continue
-
- switch_ids.append(id_)
- unique_id = f"{coordinator.mac}-switch:{id_}"
- async_remove_shelly_entity(hass, "switch", unique_id)
-
- if switch_ids:
- async_add_entities(
- RpcShellySwitchAsLight(coordinator, id_) for id_ in switch_ids
- )
- return
-
- entities: list[RpcShellyLightBase] = []
- if light_key_ids := get_rpc_key_ids(coordinator.device.status, "light"):
- entities.extend(RpcShellyLight(coordinator, id_) for id_ in light_key_ids)
- if cct_key_ids := get_rpc_key_ids(coordinator.device.status, "cct"):
- entities.extend(RpcShellyCctLight(coordinator, id_) for id_ in cct_key_ids)
- if rgb_key_ids := get_rpc_key_ids(coordinator.device.status, "rgb"):
- entities.extend(RpcShellyRgbLight(coordinator, id_) for id_ in rgb_key_ids)
- if rgbw_key_ids := get_rpc_key_ids(coordinator.device.status, "rgbw"):
- entities.extend(RpcShellyRgbwLight(coordinator, id_) for id_ in rgbw_key_ids)
-
- async_add_entities(entities)
-
- async_remove_orphaned_entities(
- hass,
- config_entry.entry_id,
- coordinator.mac,
- LIGHT_DOMAIN,
- coordinator.device.status,
- )
-
-
class BlockShellyLight(ShellyBlockEntity, LightEntity):
"""Entity that controls a light on block based Shelly devices."""
@@ -386,15 +345,27 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
super()._update_callback()
-class RpcShellyLightBase(ShellyRpcEntity, LightEntity):
+@dataclass(frozen=True, kw_only=True)
+class RpcLightDescription(RpcEntityDescription, LightEntityDescription):
+ """Description for a Shelly RPC number entity."""
+
+
+class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity):
"""Base Entity for RPC based Shelly devices."""
+ entity_description: RpcLightDescription
_component: str = "Light"
- def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcEntityDescription,
+ ) -> None:
"""Initialize light."""
- super().__init__(coordinator, f"{self._component.lower()}:{id_}")
- self._id = id_
+ super().__init__(coordinator, key, attribute, description)
+ self._attr_unique_id = f"{coordinator.mac}-{key}"
@property
def is_on(self) -> bool:
@@ -480,13 +451,21 @@ class RpcShellyCctLight(RpcShellyLightBase):
_attr_supported_color_modes = {ColorMode.COLOR_TEMP}
_attr_supported_features = LightEntityFeature.TRANSITION
- def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcEntityDescription,
+ ) -> None:
"""Initialize light."""
- color_temp_range = coordinator.device.config[f"cct:{id_}"]["ct_range"]
- self._attr_min_color_temp_kelvin = color_temp_range[0]
- self._attr_max_color_temp_kelvin = color_temp_range[1]
-
- super().__init__(coordinator, id_)
+ super().__init__(coordinator, key, attribute, description)
+ if color_temp_range := coordinator.device.config[key].get("ct_range"):
+ self._attr_min_color_temp_kelvin = color_temp_range[0]
+ self._attr_max_color_temp_kelvin = color_temp_range[1]
+ else:
+ self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE
+ self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE
@property
def color_temp_kelvin(self) -> int:
@@ -512,3 +491,58 @@ class RpcShellyRgbwLight(RpcShellyLightBase):
_attr_color_mode = ColorMode.RGBW
_attr_supported_color_modes = {ColorMode.RGBW}
_attr_supported_features = LightEntityFeature.TRANSITION
+
+
+LIGHTS: Final = {
+ "switch": RpcEntityDescription(
+ key="switch",
+ sub_key="output",
+ removal_condition=lambda config, _status, key: not is_rpc_channel_type_light(
+ config, int(key.split(":")[-1])
+ ),
+ entity_class=RpcShellySwitchAsLight,
+ ),
+ "light": RpcEntityDescription(
+ key="light",
+ sub_key="output",
+ entity_class=RpcShellyLight,
+ ),
+ "cct": RpcEntityDescription(
+ key="cct",
+ sub_key="output",
+ entity_class=RpcShellyCctLight,
+ ),
+ "rgb": RpcEntityDescription(
+ key="rgb",
+ sub_key="output",
+ entity_class=RpcShellyRgbLight,
+ ),
+ "rgbw": RpcEntityDescription(
+ key="rgbw",
+ sub_key="output",
+ entity_class=RpcShellyRgbwLight,
+ ),
+}
+
+
+@callback
+def async_setup_rpc_entry(
+ hass: HomeAssistant,
+ config_entry: ShellyConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up entities for RPC device."""
+ coordinator = config_entry.runtime_data.rpc
+ assert coordinator
+
+ async_setup_entry_rpc(
+ hass, config_entry, async_add_entities, LIGHTS, RpcShellyLight
+ )
+
+ async_remove_orphaned_entities(
+ hass,
+ config_entry.entry_id,
+ coordinator.mac,
+ LIGHT_DOMAIN,
+ coordinator.device.status,
+ )
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 78fc8261bfe..5f1f767271b 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "shelly",
"name": "Shelly",
- "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74", "@bdraco"],
+ "codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],
"config_flow": true,
"dependencies": ["bluetooth", "http", "network"],
"documentation": "https://www.home-assistant.io/integrations/shelly",
@@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
- "requirements": ["aioshelly==13.8.0"],
+ "requirements": ["aioshelly==13.11.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py
index e406d63bdc2..f77db143c85 100644
--- a/homeassistant/components/shelly/number.py
+++ b/homeassistant/components/shelly/number.py
@@ -40,6 +40,8 @@ from .utils import (
get_blu_trv_device_info,
get_device_entry_gen,
get_virtual_component_ids,
+ get_virtual_component_unit,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -124,8 +126,9 @@ class RpcBluTrvNumber(RpcNumber):
super().__init__(coordinator, key, attribute, description)
ble_addr: str = coordinator.device.config[key]["addr"]
+ fw_ver = coordinator.device.status[key].get("fw_ver")
self._attr_device_info = get_blu_trv_device_info(
- coordinator.device.config[key], ble_addr, coordinator.mac
+ coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver
)
@@ -183,16 +186,16 @@ RPC_NUMBERS: Final = {
"number": RpcNumberDescription(
key="number",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, NUMBER_PLATFORM
+ ),
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get(
config["meta"]["ui"]["view"], NumberMode.BOX
),
step_fn=lambda config: config["meta"]["ui"].get("step"),
- # If the unit is not set, the device sends an empty string
- unit=lambda config: config["meta"]["ui"]["unit"]
- if config["meta"]["ui"]["unit"]
- else None,
+ unit=get_virtual_component_unit,
method="number_set",
),
"valve_position": RpcNumberDescription(
diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py
index e1b15f04417..74203759989 100644
--- a/homeassistant/components/shelly/repairs.py
+++ b/homeassistant/components/shelly/repairs.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
-from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3
+from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3, MODEL_WALL_DISPLAY
from aioshelly.exceptions import DeviceConnectionError, RpcCallError
from aioshelly.rpc_device import RpcDevice
from awesomeversion import AwesomeVersion
@@ -21,6 +21,8 @@ from .const import (
CONF_BLE_SCANNER_MODE,
DOMAIN,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
+ WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID,
+ WALL_DISPLAY_MIN_FIRMWARE,
BLEScannerMode,
)
from .coordinator import ShellyConfigEntry
@@ -67,6 +69,42 @@ def async_manage_ble_scanner_firmware_unsupported_issue(
ir.async_delete_issue(hass, DOMAIN, issue_id)
+@callback
+def async_manage_wall_display_firmware_unsupported_issue(
+ hass: HomeAssistant,
+ entry: ShellyConfigEntry,
+) -> None:
+ """Manage the Wall Display firmware unsupported issue."""
+ issue_id = WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id)
+
+ if TYPE_CHECKING:
+ assert entry.runtime_data.rpc is not None
+
+ device = entry.runtime_data.rpc.device
+
+ if entry.data["model"] == MODEL_WALL_DISPLAY:
+ firmware = AwesomeVersion(device.shelly["ver"])
+ if firmware < WALL_DISPLAY_MIN_FIRMWARE:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=True,
+ is_persistent=True,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="wall_display_firmware_unsupported",
+ translation_placeholders={
+ "device_name": device.name,
+ "ip_address": device.ip_address,
+ "firmware": firmware,
+ },
+ data={"entry_id": entry.entry_id},
+ )
+ return
+
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
+
+
@callback
def async_manage_outbound_websocket_incorrectly_enabled_issue(
hass: HomeAssistant,
@@ -142,8 +180,8 @@ class ShellyRpcRepairsFlow(RepairsFlow):
raise NotImplementedError
-class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow):
- """Handler for BLE Scanner Firmware Update flow."""
+class FirmwareUpdateFlow(ShellyRpcRepairsFlow):
+ """Handler for Firmware Update flow."""
async def _async_step_confirm(self) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
@@ -201,8 +239,11 @@ async def async_create_fix_flow(
device = entry.runtime_data.rpc.device
- if "ble_scanner_firmware_unsupported" in issue_id:
- return BleScannerFirmwareUpdateFlow(device)
+ if (
+ "ble_scanner_firmware_unsupported" in issue_id
+ or "wall_display_firmware_unsupported" in issue_id
+ ):
+ return FirmwareUpdateFlow(device)
if "outbound_websocket_incorrectly_enabled" in issue_id:
return DisableOutboundWebSocketFlow(device)
diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py
index 0e367a9df37..c0838482b94 100644
--- a/homeassistant/components/shelly/select.py
+++ b/homeassistant/components/shelly/select.py
@@ -26,6 +26,7 @@ from .utils import (
async_remove_orphaned_entities,
get_device_entry_gen,
get_virtual_component_ids,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -40,6 +41,9 @@ RPC_SELECT_ENTITIES: Final = {
"enum": RpcSelectDescription(
key="enum",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, SELECT_PLATFORM
+ ),
),
}
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index 49e3d4773c7..6bece8f9565 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -2,9 +2,9 @@
from __future__ import annotations
-from collections.abc import Callable
from dataclasses import dataclass
-from typing import Final, cast
+from functools import partial
+from typing import Any, Final, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
@@ -31,14 +31,19 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
+ UnitOfPressure,
UnitOfTemperature,
+ UnitOfTime,
+ UnitOfVolume,
+ UnitOfVolumeFlowRate,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
-from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP
+from .const import CONF_SLEEP_PERIOD, LOGGER
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -60,8 +65,9 @@ from .utils import (
get_device_entry_gen,
get_device_uptime,
get_shelly_air_lamp_life,
- get_virtual_component_ids,
+ get_virtual_component_unit,
is_rpc_wifi_stations_disabled,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -76,7 +82,6 @@ class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription):
class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription):
"""Class to describe a RPC sensor."""
- device_class_fn: Callable[[dict], SensorDeviceClass | None] | None = None
emeter_phase: str | None = None
@@ -103,12 +108,6 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
if self.option_map:
self._attr_options = list(self.option_map.values())
- if description.device_class_fn is not None:
- if device_class := description.device_class_fn(
- coordinator.device.config[key]
- ):
- self._attr_device_class = device_class
-
@property
def native_value(self) -> StateType:
"""Return value of sensor."""
@@ -123,6 +122,34 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
return self.option_map[attribute_value]
+class RpcEnergyConsumedSensor(RpcSensor):
+ """Represent a RPC energy consumed sensor."""
+
+ @property
+ def native_value(self) -> StateType:
+ """Return value of sensor."""
+ total_energy = self.status["aenergy"]["total"]
+
+ if not isinstance(total_energy, float):
+ return None
+
+ if not isinstance(self.attribute_value, float):
+ return None
+
+ return total_energy - self.attribute_value
+
+
+class RpcPresenceSensor(RpcSensor):
+ """Represent a RPC presence sensor."""
+
+ @property
+ def available(self) -> bool:
+ """Available."""
+ available = super().available
+
+ return available and self.coordinator.device.config[self.key]["enable"]
+
+
class RpcEmeterPhaseSensor(RpcSensor):
"""Represent a RPC energy meter phase sensor."""
@@ -157,8 +184,9 @@ class RpcBluTrvSensor(RpcSensor):
super().__init__(coordinator, key, attribute, description)
ble_addr: str = coordinator.device.config[key]["addr"]
+ fw_ver = coordinator.device.status[key].get("fw_ver")
self._attr_device_info = get_blu_trv_device_info(
- coordinator.device.config[key], ble_addr, coordinator.mac
+ coordinator.device.config[key], ble_addr, coordinator.mac, fw_ver
)
@@ -382,10 +410,6 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
translation_key="lamp_life",
value=get_shelly_air_lamp_life,
suggested_display_precision=1,
- # Deprecated, remove in 2025.10
- extra_state_attributes=lambda block: {
- "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1)
- },
entity_category=EntityCategory.DIAGNOSTIC,
),
("adc", "adc"): BlockSensorDescription(
@@ -403,8 +427,6 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
options=["warmup", "normal", "fault"],
translation_key="operation",
value=lambda value: None if value == "unknown" else value,
- # Deprecated, remove in 2025.10
- extra_state_attributes=lambda block: {"self_test": block.selfTest},
),
("valve", "valve"): BlockSensorDescription(
key="valve|valve",
@@ -864,8 +886,7 @@ RPC_SENSORS: Final = {
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
- removal_condition=lambda _config, status, key: status[key].get("n_current")
- is None,
+ removal_condition=lambda _, status, key: status[key].get("n_current") is None,
entity_registry_enabled_default=False,
),
"total_current": RpcSensorDescription(
@@ -891,7 +912,21 @@ RPC_SENSORS: Final = {
"ret_energy": RpcSensorDescription(
key="switch",
sub_key="ret_aenergy",
- name="Returned energy",
+ name="Energy returned",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value=lambda status, _: status["total"],
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ removal_condition=lambda _, status, key: (
+ status[key].get("ret_aenergy") is None
+ ),
+ ),
+ "consumed_energy_switch": RpcSensorDescription(
+ key="switch",
+ sub_key="ret_aenergy",
+ name="Energy consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: status["total"],
@@ -899,7 +934,8 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
- removal_condition=lambda _config, status, key: (
+ entity_class=RpcEnergyConsumedSensor,
+ removal_condition=lambda _, status, key: (
status[key].get("ret_aenergy") is None
),
),
@@ -928,7 +964,18 @@ RPC_SENSORS: Final = {
"ret_energy_pm1": RpcSensorDescription(
key="pm1",
sub_key="ret_aenergy",
- name="Total active returned energy",
+ name="Energy returned",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value=lambda status, _: status["total"],
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ "consumed_energy_pm1": RpcSensorDescription(
+ key="pm1",
+ sub_key="ret_aenergy",
+ name="Energy consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: status["total"],
@@ -936,6 +983,7 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
+ entity_class=RpcEnergyConsumedSensor,
),
"energy_cct": RpcSensorDescription(
key="cct",
@@ -973,7 +1021,7 @@ RPC_SENSORS: Final = {
"total_act": RpcSensorDescription(
key="emdata",
sub_key="total_act",
- name="Total active energy",
+ name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -984,7 +1032,7 @@ RPC_SENSORS: Final = {
"total_act_energy": RpcSensorDescription(
key="em1data",
sub_key="total_act_energy",
- name="Total active energy",
+ name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -996,7 +1044,7 @@ RPC_SENSORS: Final = {
"a_total_act_energy": RpcSensorDescription(
key="emdata",
sub_key="a_total_act_energy",
- name="Total active energy",
+ name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1010,7 +1058,7 @@ RPC_SENSORS: Final = {
"b_total_act_energy": RpcSensorDescription(
key="emdata",
sub_key="b_total_act_energy",
- name="Total active energy",
+ name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1024,7 +1072,7 @@ RPC_SENSORS: Final = {
"c_total_act_energy": RpcSensorDescription(
key="emdata",
sub_key="c_total_act_energy",
- name="Total active energy",
+ name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1038,7 +1086,7 @@ RPC_SENSORS: Final = {
"total_act_ret": RpcSensorDescription(
key="emdata",
sub_key="total_act_ret",
- name="Total active returned energy",
+ name="Energy returned",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1049,7 +1097,7 @@ RPC_SENSORS: Final = {
"total_act_ret_energy": RpcSensorDescription(
key="em1data",
sub_key="total_act_ret_energy",
- name="Total active returned energy",
+ name="Energy returned",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1061,7 +1109,7 @@ RPC_SENSORS: Final = {
"a_total_act_ret_energy": RpcSensorDescription(
key="emdata",
sub_key="a_total_act_ret_energy",
- name="Total active returned energy",
+ name="Energy returned",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1075,7 +1123,7 @@ RPC_SENSORS: Final = {
"b_total_act_ret_energy": RpcSensorDescription(
key="emdata",
sub_key="b_total_act_ret_energy",
- name="Total active returned energy",
+ name="Energy returned",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1089,7 +1137,7 @@ RPC_SENSORS: Final = {
"c_total_act_ret_energy": RpcSensorDescription(
key="emdata",
sub_key="c_total_act_ret_energy",
- name="Total active returned energy",
+ name="Energy returned",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: float(status),
@@ -1288,7 +1336,7 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
- removal_condition=lambda _config, status, key: (status[key]["battery"] is None),
+ removal_condition=lambda _, status, key: (status[key]["battery"] is None),
),
"voltmeter": RpcSensorDescription(
key="voltmeter",
@@ -1305,9 +1353,7 @@ RPC_SENSORS: Final = {
key="voltmeter",
sub_key="xvoltage",
name="Voltmeter value",
- removal_condition=lambda _config, status, key: (
- status[key].get("xvoltage") is None
- ),
+ removal_condition=lambda _, status, key: (status[key].get("xvoltage") is None),
unit=lambda config: config["xvoltage"]["unit"] or None,
),
"analoginput": RpcSensorDescription(
@@ -1375,25 +1421,32 @@ RPC_SENSORS: Final = {
),
unit=lambda config: config["xfreq"]["unit"] or None,
),
- "text": RpcSensorDescription(
+ "text_generic": RpcSensorDescription(
key="text",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, SENSOR_PLATFORM
+ ),
+ role="generic",
),
- "number": RpcSensorDescription(
+ "number_generic": RpcSensorDescription(
key="number",
sub_key="value",
- unit=lambda config: config["meta"]["ui"]["unit"]
- if config["meta"]["ui"]["unit"]
- else None,
- device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"])
- if "role" in config
- else None,
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, SENSOR_PLATFORM
+ ),
+ unit=get_virtual_component_unit,
+ role="generic",
),
- "enum": RpcSensorDescription(
+ "enum_generic": RpcSensorDescription(
key="enum",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, SENSOR_PLATFORM
+ ),
options_fn=lambda config: config["options"],
device_class=SensorDeviceClass.ENUM,
+ role="generic",
),
"valve_position": RpcSensorDescription(
key="blutrv",
@@ -1427,9 +1480,222 @@ RPC_SENSORS: Final = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_class=RpcBluTrvSensor,
),
+ "illuminance_illumination": RpcSensorDescription(
+ key="illuminance",
+ sub_key="illumination",
+ name="Illuminance level",
+ translation_key="illuminance_level",
+ device_class=SensorDeviceClass.ENUM,
+ options=["dark", "twilight", "bright"],
+ ),
+ "number_current_humidity": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="current_humidity",
+ ),
+ "number_current_temperature": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="current_temperature",
+ ),
+ "number_flow_rate": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_MINUTE,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="flow_rate",
+ ),
+ "number_water_pressure": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfPressure.KPA,
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="water_pressure",
+ ),
+ "number_water_temperature": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="water_temperature",
+ ),
+ "number_work_state": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ translation_key="charger_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "charger_charging",
+ "charger_end",
+ "charger_fault",
+ "charger_free",
+ "charger_free_fault",
+ "charger_insert",
+ "charger_pause",
+ "charger_wait",
+ ],
+ role="work_state",
+ ),
+ "number_energy_charge": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ role="energy_charge",
+ ),
+ "number_time_charge": RpcSensorDescription(
+ key="number",
+ sub_key="value",
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ suggested_display_precision=0,
+ device_class=SensorDeviceClass.DURATION,
+ role="time_charge",
+ ),
+ "presence_num_objects": RpcSensorDescription(
+ key="presence",
+ sub_key="num_objects",
+ translation_key="detected_objects",
+ name="Detected objects",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_class=RpcPresenceSensor,
+ ),
+ "presencezone_num_objects": RpcSensorDescription(
+ key="presencezone",
+ sub_key="num_objects",
+ translation_key="detected_objects",
+ name="Detected objects",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_class=RpcPresenceSensor,
+ ),
+ "object_water_consumption": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ value=lambda status, _: float(status["counter"]["total"]),
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ suggested_display_precision=3,
+ device_class=SensorDeviceClass.WATER,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ role="water_consumption",
+ ),
+ "object_energy_consumption": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ value=lambda status, _: float(status["counter"]["total"]),
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ role="phase_info",
+ ),
+ "object_total_act_energy": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ name="Total Active Energy",
+ value=lambda status, _: float(status["total_act_energy"]),
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ role="phase_info",
+ ),
+ "object_total_power": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ name="Total Power",
+ value=lambda status, _: float(status["total_power"]),
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="phase_info",
+ ),
+ "object_phase_a_voltage": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ name="Phase A voltage",
+ value=lambda status, _: float(status["phase_a"]["voltage"]),
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="phase_info",
+ ),
+ "object_phase_b_voltage": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ name="Phase B voltage",
+ value=lambda status, _: float(status["phase_b"]["voltage"]),
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="phase_info",
+ ),
+ "object_phase_c_voltage": RpcSensorDescription(
+ key="object",
+ sub_key="value",
+ name="Phase C voltage",
+ value=lambda status, _: float(status["phase_c"]["voltage"]),
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=1,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ role="phase_info",
+ ),
}
+@callback
+def async_migrate_unique_ids(
+ coordinator: ShellyRpcCoordinator,
+ entity_entry: er.RegistryEntry,
+) -> dict[str, Any] | None:
+ """Migrate sensor unique IDs to include role."""
+ if not entity_entry.entity_id.startswith("sensor."):
+ return None
+
+ for sensor_id in ("text", "number", "enum"):
+ old_unique_id = entity_entry.unique_id
+ if old_unique_id.endswith(f"-{sensor_id}"):
+ if entity_entry.original_device_class == SensorDeviceClass.HUMIDITY:
+ new_unique_id = f"{old_unique_id}_current_humidity"
+ elif entity_entry.original_device_class == SensorDeviceClass.TEMPERATURE:
+ new_unique_id = f"{old_unique_id}_current_temperature"
+ else:
+ new_unique_id = f"{old_unique_id}_generic"
+ LOGGER.debug(
+ "Migrating unique_id for %s entity from [%s] to [%s]",
+ entity_entry.entity_id,
+ old_unique_id,
+ new_unique_id,
+ )
+ return {
+ "new_unique_id": entity_entry.unique_id.replace(
+ old_unique_id, new_unique_id
+ )
+ }
+
+ return None
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
@@ -1449,6 +1715,12 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.rpc
assert coordinator
+ await er.async_migrate_entries(
+ hass,
+ config_entry.entry_id,
+ partial(async_migrate_unique_ids, coordinator),
+ )
+
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
)
@@ -1460,21 +1732,6 @@ async def async_setup_entry(
SENSOR_PLATFORM,
coordinator.device.status,
)
-
- # the user can remove virtual components from the device configuration, so
- # we need to remove orphaned entities
- virtual_component_ids = get_virtual_component_ids(
- coordinator.device.config, SENSOR_PLATFORM
- )
- for component in ("enum", "number", "text"):
- async_remove_orphaned_entities(
- hass,
- config_entry.entry_id,
- coordinator.mac,
- SENSOR_PLATFORM,
- virtual_component_ids,
- component,
- )
return
if config_entry.data[CONF_SLEEP_PERIOD]:
diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json
index 2bb5cd73bfd..443abc119e5 100644
--- a/homeassistant/components/shelly/strings.json
+++ b/homeassistant/components/shelly/strings.json
@@ -118,15 +118,12 @@
}
},
"entity": {
- "binary_sensor": {
- "gas": {
+ "climate": {
+ "thermostat": {
"state_attributes": {
- "detected": {
+ "preset_mode": {
"state": {
- "none": "None",
- "mild": "Mild",
- "heavy": "Heavy",
- "test": "Test"
+ "frost_protection": "Frost protection"
}
}
}
@@ -155,6 +152,21 @@
}
},
"sensor": {
+ "charger_state": {
+ "state": {
+ "charger_charging": "[%key:common::state::charging%]",
+ "charger_end": "Charge completed",
+ "charger_fault": "Error while charging",
+ "charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]",
+ "charger_free_fault": "Can not release plug",
+ "charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]",
+ "charger_pause": "Charging paused by charger",
+ "charger_wait": "Charging paused by vehicle"
+ }
+ },
+ "detected_objects": {
+ "unit_of_measurement": "objects"
+ },
"gas_detected": {
"state": {
"none": "None",
@@ -178,16 +190,6 @@
"warmup": "Warm-up",
"normal": "[%key:common::state::normal%]",
"fault": "[%key:common::state::fault%]"
- },
- "state_attributes": {
- "self_test": {
- "state": {
- "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]",
- "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]",
- "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]",
- "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]"
- }
- }
}
},
"self_test": {
@@ -217,6 +219,13 @@
"opened": "Opened",
"opening": "[%key:common::state::opening%]"
}
+ },
+ "illuminance_level": {
+ "state": {
+ "dark": "Dark",
+ "twilight": "Twilight",
+ "bright": "Bright"
+ }
}
}
},
@@ -302,6 +311,21 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
}
+ },
+ "wall_display_firmware_unsupported": {
+ "title": "{device_name} is running outdated firmware",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "{device_name} is running outdated firmware",
+ "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware}. This firmware version will not be supported by Shelly integration starting from Home Assistant 2025.11.0.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version."
+ }
+ },
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "update_not_available": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::fix_flow::abort::update_not_available%]"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py
index 1c184d260f8..0518858868d 100644
--- a/homeassistant/components/shelly/switch.py
+++ b/homeassistant/components/shelly/switch.py
@@ -37,6 +37,7 @@ from .utils import (
get_virtual_component_ids,
is_block_exclude_from_relay,
is_rpc_exclude_from_relay,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -89,6 +90,9 @@ RPC_SWITCHES = {
"boolean": RpcSwitchDescription(
key="boolean",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, SWITCH_PLATFORM
+ ),
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py
index d89531e2338..5a514771a3f 100644
--- a/homeassistant/components/shelly/text.py
+++ b/homeassistant/components/shelly/text.py
@@ -26,6 +26,7 @@ from .utils import (
async_remove_orphaned_entities,
get_device_entry_gen,
get_virtual_component_ids,
+ is_view_for_platform,
)
PARALLEL_UPDATES = 0
@@ -40,6 +41,9 @@ RPC_TEXT_ENTITIES: Final = {
"text": RpcTextDescription(
key="text",
sub_key="value",
+ removal_condition=lambda config, _status, key: not is_view_for_platform(
+ config, key, TEXT_PLATFORM
+ ),
),
}
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index 2ee960348dd..6cd90f1feb9 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -57,6 +57,7 @@ from .const import (
COMPONENT_ID_PATTERN,
CONF_COAP_PORT,
CONF_GEN,
+ DEVICE_UNIT_MAP,
DEVICES_WITHOUT_FIRMWARE_CHANGELOG,
DOMAIN,
FIRMWARE_UNSUPPORTED_ISSUE_ID,
@@ -401,7 +402,7 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
if key in device.config and key != "em:0":
# workaround for Pro 3EM, we don't want to get name for em:0
if component_name := device.config[key].get("name"):
- if component in (*VIRTUAL_COMPONENTS, "script"):
+ if component in (*VIRTUAL_COMPONENTS, "presencezone", "script"):
return cast(str, component_name)
return cast(str, component_name) if instances == 1 else None
@@ -475,6 +476,19 @@ def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]:
return [int(k.split(":")[1]) for k in keys_dict if k.startswith(f"{key}:")]
+def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None:
+ """Return key by role for RPC device from a dict."""
+ for key, value in keys_dict.items():
+ if value.get("role") == role:
+ return key
+ return None
+
+
+def id_from_key(key: str) -> int:
+ """Return id from key."""
+ return int(key.split(":")[-1])
+
+
def is_rpc_momentary_input(
config: dict[str, Any], status: dict[str, Any], key: str
) -> bool:
@@ -551,8 +565,15 @@ def percentage_to_brightness(percentage: int) -> int:
def mac_address_from_name(name: str) -> str | None:
"""Convert a name to a mac address."""
- mac = name.partition(".")[0].partition("-")[-1]
- return mac.upper() if len(mac) == 12 else None
+ base = name.split(".", 1)[0]
+ if "-" not in base:
+ return None
+
+ mac = base.rsplit("-", 1)[-1]
+ if len(mac) != 12 or not all(char in "0123456789abcdefABCDEF" for char in mac):
+ return None
+
+ return mac.upper()
def get_release_url(gen: int, model: str, beta: bool) -> str | None:
@@ -629,11 +650,6 @@ def async_remove_shelly_rpc_entities(
entity_reg.async_remove(entity_id)
-def is_rpc_thermostat_mode(ident: int, status: dict[str, Any]) -> bool:
- """Return True if 'thermostat:' is present in the status."""
- return f"thermostat:{ident}" in status
-
-
def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str]:
"""Return a list of virtual component IDs for a platform."""
component = VIRTUAL_COMPONENTS_MAP.get(platform)
@@ -647,12 +663,31 @@ def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str
ids.extend(
k
for k, v in config.items()
- if k.startswith(comp_type) and v["meta"]["ui"]["view"] in component["modes"]
+ if k.startswith(comp_type)
+ # default to button view if not set, workaround for Wall Display
+ and v.get("meta", {"ui": {"view": "button"}})["ui"]["view"]
+ in component["modes"]
)
return ids
+def is_view_for_platform(config: dict[str, Any], key: str, platform: str) -> bool:
+ """Return true if the virtual component view match the platform."""
+ component = VIRTUAL_COMPONENTS_MAP[platform]
+ view = config[key]["meta"]["ui"]["view"]
+ return view in component["modes"]
+
+
+def get_virtual_component_unit(config: dict[str, Any]) -> str | None:
+ """Return the unit of a virtual component.
+
+ If the unit is not set, the device sends an empty string
+ """
+ unit = config["meta"]["ui"]["unit"]
+ return DEVICE_UNIT_MAP.get(unit, unit) if unit else None
+
+
@callback
def async_remove_orphaned_entities(
hass: HomeAssistant,
@@ -667,25 +702,21 @@ def async_remove_orphaned_entities(
entity_reg = er.async_get(hass)
device_reg = dr.async_get(hass)
- if not (
- devices := device_reg.devices.get_devices_for_config_entry_id(config_entry_id)
- ):
- return
+ devices = device_reg.devices.get_devices_for_config_entry_id(config_entry_id)
+ for device in devices:
+ entities = er.async_entries_for_device(entity_reg, device.id, True)
+ for entity in entities:
+ if not entity.entity_id.startswith(platform):
+ continue
+ if key_suffix is not None and key_suffix not in entity.unique_id:
+ continue
+ # we are looking for the component ID, e.g. boolean:201, em1data:1
+ if not (match := COMPONENT_ID_PATTERN.search(entity.unique_id)):
+ continue
- device_id = devices[0].id
- entities = er.async_entries_for_device(entity_reg, device_id, True)
- for entity in entities:
- if not entity.entity_id.startswith(platform):
- continue
- if key_suffix is not None and key_suffix not in entity.unique_id:
- continue
- # we are looking for the component ID, e.g. boolean:201, em1data:1
- if not (match := COMPONENT_ID_PATTERN.search(entity.unique_id)):
- continue
-
- key = match.group()
- if key not in keys:
- orphaned_entities.append(entity.unique_id.split("-", 1)[1])
+ key = match.group()
+ if key not in keys:
+ orphaned_entities.append(entity.unique_id.split("-", 1)[1])
if orphaned_entities:
async_remove_shelly_rpc_entities(hass, platform, mac, orphaned_entities)
@@ -801,7 +832,7 @@ def get_rpc_device_info(
def get_blu_trv_device_info(
- config: dict[str, Any], ble_addr: str, parent_mac: str
+ config: dict[str, Any], ble_addr: str, parent_mac: str, fw_ver: str | None
) -> DeviceInfo:
"""Return device info for RPC device."""
model_id = config.get("local_name")
@@ -813,6 +844,7 @@ def get_blu_trv_device_info(
model=BLU_TRV_MODEL_NAME.get(model_id) if model_id else None,
model_id=config.get("local_name"),
name=config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}",
+ sw_version=fw_ver,
)
@@ -873,3 +905,32 @@ def remove_stale_blu_trv_devices(
LOGGER.debug("Removing stale BLU TRV device %s", device.name)
dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id)
+
+
+@callback
+def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove sub devices without entities."""
+ dev_reg = dr.async_get(hass)
+ entity_reg = er.async_get(hass)
+
+ devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id)
+
+ for device in devices:
+ if not device.via_device_id:
+ # Device is not a sub-device, skip
+ continue
+
+ if er.async_entries_for_device(entity_reg, device.id, True):
+ # Device has entities, skip
+ continue
+
+ if any(identifier[0] == DOMAIN for identifier in device.identifiers):
+ LOGGER.debug("Removing empty sub-device %s", device.name)
+ dev_reg.async_update_device(
+ device.id, remove_config_entry_id=entry.entry_id
+ )
+
+
+def format_ble_addr(ble_addr: str) -> str:
+ """Format BLE address to use in unique_id."""
+ return ble_addr.replace(":", "").upper()
diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py
index b748172ba3d..65fbfa79b4d 100644
--- a/homeassistant/components/shelly/valve.py
+++ b/homeassistant/components/shelly/valve.py
@@ -17,11 +17,15 @@ from homeassistant.components.valve import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry
+from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE
+from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
+ RpcEntityDescription,
ShellyBlockAttributeEntity,
+ ShellyRpcAttributeEntity,
async_setup_block_attribute_entities,
+ async_setup_entry_rpc,
)
from .utils import async_remove_shelly_entity, get_device_entry_gen
@@ -33,6 +37,11 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription):
"""Class to describe a BLOCK valve."""
+@dataclass(kw_only=True, frozen=True)
+class RpcValveDescription(RpcEntityDescription, ValveEntityDescription):
+ """Class to describe a RPC virtual valve."""
+
+
GAS_VALVE = BlockValveDescription(
key="valve|valve",
name="Valve",
@@ -41,6 +50,83 @@ GAS_VALVE = BlockValveDescription(
)
+class RpcShellyBaseWaterValve(ShellyRpcAttributeEntity, ValveEntity):
+ """Base Entity for RPC Shelly Water Valves."""
+
+ entity_description: RpcValveDescription
+ _attr_device_class = ValveDeviceClass.WATER
+ _id: int
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcEntityDescription,
+ ) -> None:
+ """Initialize RPC water valve."""
+ super().__init__(coordinator, key, attribute, description)
+ self._attr_name = None # Main device entity
+
+
+class RpcShellyWaterValve(RpcShellyBaseWaterValve):
+ """Entity that controls a valve on RPC Shelly Water Valve."""
+
+ _attr_supported_features = (
+ ValveEntityFeature.OPEN
+ | ValveEntityFeature.CLOSE
+ | ValveEntityFeature.SET_POSITION
+ )
+ _attr_reports_position = True
+
+ @property
+ def current_valve_position(self) -> int:
+ """Return current position of valve."""
+ return cast(int, self.attribute_value)
+
+ async def async_set_valve_position(self, position: int) -> None:
+ """Move the valve to a specific position."""
+ await self.coordinator.device.number_set(self._id, position)
+
+
+class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve):
+ """Entity that controls a valve on RPC Shelly NEO Water Valve."""
+
+ _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
+ _attr_reports_position = False
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Return if the valve is closed or not."""
+ return not self.attribute_value
+
+ async def async_open_valve(self, **kwargs: Any) -> None:
+ """Open valve."""
+ await self.coordinator.device.boolean_set(self._id, True)
+
+ async def async_close_valve(self, **kwargs: Any) -> None:
+ """Close valve."""
+ await self.coordinator.device.boolean_set(self._id, False)
+
+
+RPC_VALVES: dict[str, RpcValveDescription] = {
+ "water_valve": RpcValveDescription(
+ key="number",
+ sub_key="value",
+ role="position",
+ entity_class=RpcShellyWaterValve,
+ models={MODEL_FRANKEVER_WATER_VALVE},
+ ),
+ "neo_water_valve": RpcValveDescription(
+ key="boolean",
+ sub_key="value",
+ role="state",
+ entity_class=RpcShellyNeoWaterValve,
+ models={MODEL_NEO_WATER_VALVE},
+ ),
+}
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
@@ -48,7 +134,24 @@ async def async_setup_entry(
) -> None:
"""Set up valves for device."""
if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS:
- async_setup_block_entry(hass, config_entry, async_add_entities)
+ return async_setup_block_entry(hass, config_entry, async_add_entities)
+
+ return async_setup_rpc_entry(hass, config_entry, async_add_entities)
+
+
+@callback
+def async_setup_rpc_entry(
+ hass: HomeAssistant,
+ config_entry: ShellyConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up entities for RPC device."""
+ coordinator = config_entry.runtime_data.rpc
+ assert coordinator
+
+ async_setup_entry_rpc(
+ hass, config_entry, async_add_entities, RPC_VALVES, RpcShellyWaterValve
+ )
@callback
diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py
index 29e366fc5dd..06bb692621a 100644
--- a/homeassistant/components/shopping_list/intent.py
+++ b/homeassistant/components/shopping_list/intent.py
@@ -60,7 +60,6 @@ class CompleteItemIntent(intent.IntentHandler):
response = intent_obj.create_response()
response.async_set_speech_slots({"completed_items": complete_items})
- response.response_type = intent.IntentResponseType.ACTION_DONE
return response
diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py
index bb6a0669a99..a3bed652876 100644
--- a/homeassistant/components/sia/alarm_control_panel.py
+++ b/homeassistant/components/sia/alarm_control_panel.py
@@ -47,7 +47,7 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription(
"CP": AlarmControlPanelState.ARMED_AWAY,
"CQ": AlarmControlPanelState.ARMED_AWAY,
"CS": AlarmControlPanelState.ARMED_AWAY,
- "CF": AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
+ "CF": AlarmControlPanelState.ARMED_AWAY,
"NP": AlarmControlPanelState.DISARMED,
"NO": AlarmControlPanelState.DISARMED,
"OA": AlarmControlPanelState.DISARMED,
diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json
index df2e11b5659..00b610e8dc8 100644
--- a/homeassistant/components/sia/strings.json
+++ b/homeassistant/components/sia/strings.json
@@ -11,7 +11,7 @@
"zones": "Number of zones for the account",
"additional_account": "Additional accounts"
},
- "title": "Create a connection for SIA-based alarm systems."
+ "title": "Create a connection for SIA-based alarm systems"
},
"additional_account": {
"data": {
@@ -21,7 +21,7 @@
"zones": "[%key:component::sia::config::step::user::data::zones%]",
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]"
},
- "title": "Add another account to the current port."
+ "title": "Add another account to the current port"
}
},
"abort": {
@@ -45,7 +45,7 @@
"zones": "[%key:component::sia::config::step::user::data::zones%]"
},
"description": "Set the options for account: {account}",
- "title": "Options for the SIA Setup."
+ "title": "Options for the SIA setup"
}
}
}
diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py
index bc007eaa689..06de7d91583 100644
--- a/homeassistant/components/signal_messenger/notify.py
+++ b/homeassistant/components/signal_messenger/notify.py
@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
+ ATTR_TARGET,
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
)
@@ -98,10 +99,12 @@ class SignalNotificationService(BaseNotificationService):
self._signal_cli_rest_api = signal_cli_rest_api
def send_message(self, message: str = "", **kwargs: Any) -> None:
- """Send a message to a one or more recipients. Additionally a file can be attached."""
+ """Send a message to one or more recipients. Additionally a file can be attached."""
_LOGGER.debug("Sending signal message")
+ recipients: list[str] = kwargs.get(ATTR_TARGET) or self._recp_nrs
+
data = kwargs.get(ATTR_DATA)
try:
@@ -117,7 +120,7 @@ class SignalNotificationService(BaseNotificationService):
try:
self._signal_cli_rest_api.send_message(
message,
- self._recp_nrs,
+ recipients,
filenames,
attachments_as_bytes,
text_mode="normal" if data is None else data.get(ATTR_TEXTMODE),
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 67bf94c61ae..f2ef3ce9063 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -290,7 +290,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SimpliSafe as config entry."""
_async_standardize_config_entry(hass, entry)
- _verify_domain_control = verify_domain_control(hass, DOMAIN)
+ _verify_domain_control = verify_domain_control(DOMAIN)
websession = aiohttp_client.async_get_clientsession(hass)
try:
diff --git a/homeassistant/components/slack/strings.json b/homeassistant/components/slack/strings.json
index 13b48644ffd..960ae3cccbc 100644
--- a/homeassistant/components/slack/strings.json
+++ b/homeassistant/components/slack/strings.json
@@ -5,14 +5,14 @@
"description": "Refer to the documentation on getting your Slack API key.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
- "default_channel": "Default Channel",
+ "default_channel": "Default channel",
"icon": "Icon",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"api_key": "The Slack API token to use for sending Slack messages.",
"default_channel": "The channel to post to if no channel is specified when sending a message.",
- "icon": "Use one of the Slack emojis as an Icon for the supplied username.",
+ "icon": "Use one of the Slack emojis as an icon for the supplied username.",
"username": "Home Assistant will post to Slack using the username specified."
}
}
diff --git a/homeassistant/components/sleep_as_android/manifest.json b/homeassistant/components/sleep_as_android/manifest.json
index fbac134ffa1..f2b38d2089b 100644
--- a/homeassistant/components/sleep_as_android/manifest.json
+++ b/homeassistant/components/sleep_as_android/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/sleep_as_android",
"iot_class": "local_push",
- "quality_scale": "silver"
+ "quality_scale": "platinum"
}
diff --git a/homeassistant/components/sleep_as_android/sensor.py b/homeassistant/components/sleep_as_android/sensor.py
index 966e851f633..67b52ae9153 100644
--- a/homeassistant/components/sleep_as_android/sensor.py
+++ b/homeassistant/components/sleep_as_android/sensor.py
@@ -85,6 +85,12 @@ class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor):
):
self._attr_native_value = label
+ if (
+ data[ATTR_EVENT] == "alarm_rescheduled"
+ and data.get(ATTR_VALUE1) is None
+ ):
+ self._attr_native_value = None
+
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py
index cbc3e653739..e4c8179d494 100644
--- a/homeassistant/components/slide_local/coordinator.py
+++ b/homeassistant/components/slide_local/coordinator.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DEFAULT_OFFSET, DOMAIN
+from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -100,19 +100,22 @@ class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]):
data["pos"] = max(0, min(1, data["pos"]))
+ if not self.config_entry.options.get(CONF_INVERT_POSITION, False):
+ # For slide 0->open, 1->closed; for HA 0->closed, 1->open
+ # Value has therefore to be inverted, unless CONF_INVERT_POSITION is true
+ data["pos"] = 1 - data["pos"]
+
if oldpos is None or oldpos == data["pos"]:
data["state"] = (
- STATE_CLOSED if data["pos"] > (1 - DEFAULT_OFFSET) else STATE_OPEN
+ STATE_CLOSED if data["pos"] < DEFAULT_OFFSET else STATE_OPEN
)
- elif oldpos < data["pos"]:
+ elif oldpos > data["pos"]:
data["state"] = (
- STATE_CLOSED
- if data["pos"] >= (1 - DEFAULT_OFFSET)
- else STATE_CLOSING
+ STATE_CLOSED if data["pos"] <= DEFAULT_OFFSET else STATE_CLOSING
)
else:
data["state"] = (
- STATE_OPEN if data["pos"] <= DEFAULT_OFFSET else STATE_OPENING
+ STATE_OPEN if data["pos"] >= (1 - DEFAULT_OFFSET) else STATE_OPENING
)
_LOGGER.debug("Data successfully updated: %s", data)
diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py
index 6bb3f338cb8..29ff7d2ddb4 100644
--- a/homeassistant/components/slide_local/cover.py
+++ b/homeassistant/components/slide_local/cover.py
@@ -78,8 +78,6 @@ class SlideCoverLocal(SlideEntity, CoverEntity):
if pos is not None:
if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET:
pos = round(pos)
- if not self.invert:
- pos = 1 - pos
pos = int(pos * 100)
return pos
diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py
index e08b9ade9fc..66dd9c9993d 100644
--- a/homeassistant/components/sma/config_flow.py
+++ b/homeassistant/components/sma/config_flow.py
@@ -151,7 +151,7 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
- errors, device_info = await self._handle_user_input(
+ errors, _device_info = await self._handle_user_input(
user_input={
**reauth_entry.data,
CONF_PASSWORD: user_input[CONF_PASSWORD],
@@ -224,7 +224,7 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Confirm discovery."""
errors: dict[str, str] = {}
if user_input is not None:
- errors, device_info = await self._handle_user_input(
+ errors, _device_info = await self._handle_user_input(
user_input=user_input, discovery=True
)
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 9c7621037c7..fb4282419ce 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -502,6 +502,9 @@ KEEP_CAPABILITY_QUIRK: dict[
lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None
),
Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True,
+ Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: (
+ lambda status: status[Attribute.LIGHTING].value is not None
+ ),
}
diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py
index f87c9bbfcef..28c1c9c3782 100644
--- a/homeassistant/components/smartthings/climate.py
+++ b/homeassistant/components/smartthings/climate.py
@@ -14,6 +14,9 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
+ PRESET_BOOST,
+ PRESET_NONE,
+ PRESET_SLEEP,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
@@ -97,11 +100,23 @@ HEAT_PUMP_AC_MODE_TO_HA = {
"heat": HVACMode.HEAT,
}
+PRESET_MODE_TO_HA = {
+ "off": PRESET_NONE,
+ "windFree": "wind_free",
+ "sleep": PRESET_SLEEP,
+ "windFreeSleep": "wind_free_sleep",
+ "speed": PRESET_BOOST,
+ "quiet": "quiet",
+ "longWind": "long_wind",
+ "smart": "smart",
+}
+
+HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()}
+
HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items()}
WIND = "wind"
FAN = "fan"
-WINDFREE = "windFree"
_LOGGER = logging.getLogger(__name__)
@@ -363,6 +378,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
_attr_name = None
+ _attr_translation_key = "air_conditioner"
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
@@ -577,14 +593,13 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
- """Return the preset mode."""
+ """Return the current preset mode."""
if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):
mode = self.get_attribute_value(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.AC_OPTIONAL_MODE,
)
- if mode == WINDFREE:
- return WINDFREE
+ return PRESET_MODE_TO_HA.get(mode)
return None
def _determine_preset_modes(self) -> list[str] | None:
@@ -594,16 +609,24 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.SUPPORTED_AC_OPTIONAL_MODE,
)
- if supported_modes and WINDFREE in supported_modes:
- return [WINDFREE]
+ modes = []
+ for mode in supported_modes:
+ if (ha_mode := PRESET_MODE_TO_HA.get(mode)) is not None:
+ modes.append(ha_mode)
+ else:
+ _LOGGER.warning(
+ "Unknown preset mode: %s, please report at https://github.com/home-assistant/core/issues",
+ mode,
+ )
+ return modes
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
- """Set special modes (currently only windFree is supported)."""
+ """Set optional AC modes."""
await self.execute_device_command(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Command.SET_AC_OPTIONAL_MODE,
- argument=preset_mode,
+ argument=HA_MODE_TO_PRESET_MODE[preset_mode],
)
def _determine_hvac_modes(self) -> list[HVACMode]:
diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json
index 668dff961ee..aad9182576d 100644
--- a/homeassistant/components/smartthings/icons.json
+++ b/homeassistant/components/smartthings/icons.json
@@ -31,6 +31,17 @@
"default": "mdi:stop"
}
},
+ "climate": {
+ "air_conditioner": {
+ "state_attributes": {
+ "fan_mode": {
+ "state": {
+ "turbo": "mdi:wind-power"
+ }
+ }
+ }
+ }
+ },
"number": {
"washer_rinse_cycles": {
"default": "mdi:waves-arrow-up"
@@ -106,6 +117,13 @@
"on": "mdi:water"
}
},
+ "display_lighting": {
+ "default": "mdi:lightbulb",
+ "state": {
+ "on": "mdi:lightbulb-on",
+ "off": "mdi:lightbulb-off"
+ }
+ },
"wrinkle_prevent": {
"default": "mdi:tumble-dryer",
"state": {
diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json
index 951d1372a69..96c6d94da4f 100644
--- a/homeassistant/components/smartthings/manifest.json
+++ b/homeassistant/components/smartthings/manifest.json
@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
- "requirements": ["pysmartthings==3.2.9"]
+ "requirements": ["pysmartthings==3.3.0"]
}
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index d3e2ab09a3f..42581a2807e 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -1151,8 +1151,11 @@ async def async_setup_entry(
)
and (
not description.exists_fn
- or description.exists_fn(
- device.status[MAIN][capability][attribute]
+ or (
+ component == MAIN
+ and description.exists_fn(
+ device.status[MAIN][capability][attribute]
+ )
)
)
and (
diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json
index 53e08546583..fb6b8465186 100644
--- a/homeassistant/components/smartthings/strings.json
+++ b/homeassistant/components/smartthings/strings.json
@@ -78,6 +78,26 @@
"name": "[%key:common::action::stop%]"
}
},
+ "climate": {
+ "air_conditioner": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "wind_free": "WindFree",
+ "wind_free_sleep": "WindFree sleep",
+ "quiet": "Quiet",
+ "long_wind": "Long wind",
+ "smart": "Smart"
+ }
+ },
+ "fan_mode": {
+ "state": {
+ "turbo": "Turbo"
+ }
+ }
+ }
+ }
+ },
"event": {
"button": {
"state": {
@@ -141,9 +161,9 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "low": "Low",
+ "low": "[%key:common::state::low%]",
"mid": "Mid",
- "high": "High",
+ "high": "[%key:common::state::high%]",
"extra_high": "Extra high"
}
},
@@ -194,7 +214,7 @@
"state": {
"none": "None",
"heavy": "Heavy",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"light": "Light",
"extra_light": "Extra light",
"extra_heavy": "Extra heavy",
@@ -604,6 +624,9 @@
"bubble_soak": {
"name": "Bubble Soak"
},
+ "display_lighting": {
+ "name": "Display lighting"
+ },
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
@@ -623,7 +646,7 @@
"name": "Power freeze"
},
"auto_cycle_link": {
- "name": "Auto cycle link"
+ "name": "Auto Cycle Link"
},
"sanitize": {
"name": "Sanitize"
diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py
index 1f75e1976f6..bb883d0d41c 100644
--- a/homeassistant/components/smartthings/switch.py
+++ b/homeassistant/components/smartthings/switch.py
@@ -68,6 +68,13 @@ SWITCH = SmartThingsSwitchEntityDescription(
CAPABILITY_TO_COMMAND_SWITCHES: dict[
Capability | str, SmartThingsCommandSwitchEntityDescription
] = {
+ Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: SmartThingsCommandSwitchEntityDescription(
+ key=Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING,
+ translation_key="display_lighting",
+ status_attribute=Attribute.LIGHTING,
+ command=Command.SET_LIGHTING_LEVEL,
+ entity_category=EntityCategory.CONFIG,
+ ),
Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription(
key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT,
translation_key="wrinkle_prevent",
diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json
index c295647b8e5..fb102a8f9e9 100644
--- a/homeassistant/components/smarty/manifest.json
+++ b/homeassistant/components/smarty/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pysmarty2"],
- "requirements": ["pysmarty2==0.10.2"]
+ "requirements": ["pysmarty2==0.10.3"]
}
diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json
index 0af692b800c..391c1e02dd2 100644
--- a/homeassistant/components/smhi/manifest.json
+++ b/homeassistant/components/smhi/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smhi",
"iot_class": "cloud_polling",
"loggers": ["pysmhi"],
- "requirements": ["pysmhi==1.0.2"]
+ "requirements": ["pysmhi==1.1.0"]
}
diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py
index eb963ce6a42..1f94a1c4fae 100644
--- a/homeassistant/components/snmp/device_tracker.py
+++ b/homeassistant/components/snmp/device_tracker.py
@@ -147,6 +147,13 @@ class SnmpScanner(DeviceScanner):
# We have no names
return None
+ async def async_get_extra_attributes(self, device: str) -> dict:
+ """Return the extra attributes of the given device or an empty dictionary if we have none."""
+ for client in self.last_results:
+ if client.get("mac") and device == client["mac"]:
+ return {"mac": client["mac"]}
+ return {}
+
async def _async_update_info(self):
"""Ensure the information from the device is up to date.
diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py
index 20d94be7c03..bf4dc07f96c 100644
--- a/homeassistant/components/snoo/__init__.py
+++ b/homeassistant/components/snoo/__init__.py
@@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.EVENT,
Platform.SELECT,
Platform.SENSOR,
diff --git a/homeassistant/components/snoo/button.py b/homeassistant/components/snoo/button.py
new file mode 100644
index 00000000000..c7faabb142f
--- /dev/null
+++ b/homeassistant/components/snoo/button.py
@@ -0,0 +1,69 @@
+"""Support for Snoo Buttons."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from python_snoo.containers import SnooDevice
+from python_snoo.exceptions import SnooCommandException
+from python_snoo.snoo import Snoo
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+@dataclass(kw_only=True, frozen=True)
+class SnooButtonEntityDescription(ButtonEntityDescription):
+ """Description for Snoo button entities."""
+
+ press_fn: Callable[[Snoo, SnooDevice], Awaitable[None]]
+
+
+BUTTON_DESCRIPTIONS: list[SnooButtonEntityDescription] = [
+ SnooButtonEntityDescription(
+ key="start_snoo",
+ translation_key="start_snoo",
+ press_fn=lambda snoo, device: snoo.start_snoo(
+ device,
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up buttons for Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooButton(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in BUTTON_DESCRIPTIONS
+ )
+
+
+class SnooButton(SnooDescriptionEntity, ButtonEntity):
+ """Representation of a Snoo button."""
+
+ entity_description: SnooButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.entity_description.press_fn(
+ self.coordinator.snoo,
+ self.coordinator.device,
+ )
+ except SnooCommandException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=f"{self.entity_description.key}_failed",
+ translation_placeholders={"name": str(self.name)},
+ ) from err
diff --git a/homeassistant/components/snoo/icons.json b/homeassistant/components/snoo/icons.json
new file mode 100644
index 00000000000..44504a4c969
--- /dev/null
+++ b/homeassistant/components/snoo/icons.json
@@ -0,0 +1,9 @@
+{
+ "entity": {
+ "button": {
+ "start_snoo": {
+ "default": "mdi:play"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json
index e4a5c634a68..c86e0dd8907 100644
--- a/homeassistant/components/snoo/strings.json
+++ b/homeassistant/components/snoo/strings.json
@@ -25,6 +25,9 @@
"select_failed": {
"message": "Error while updating {name} to {option}"
},
+ "start_snoo_failed": {
+ "message": "Starting {name} failed"
+ },
"switch_on_failed": {
"message": "Turning {name} on failed"
},
@@ -41,6 +44,11 @@
"name": "Right safety clip"
}
},
+ "button": {
+ "start_snoo": {
+ "name": "Start"
+ }
+ },
"event": {
"event": {
"name": "Snoo event",
diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json
index 4a4101a2dd3..ea8698b9684 100644
--- a/homeassistant/components/solarlog/manifest.json
+++ b/homeassistant/components/solarlog/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
"quality_scale": "platinum",
- "requirements": ["solarlog_cli==0.5.0"]
+ "requirements": ["solarlog_cli==0.6.0"]
}
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
index cbce25197b0..0231fca42dd 100644
--- a/homeassistant/components/sonos/__init__.py
+++ b/homeassistant/components/sonos/__init__.py
@@ -59,6 +59,7 @@ from .const import (
from .exception import SonosUpdateError
from .favorites import SonosFavorites
from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones
+from .services import async_setup_services
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@@ -104,6 +105,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
)
+ async_setup_services(hass)
+
return True
diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py
index ac2e3f50f13..20e079c901d 100644
--- a/homeassistant/components/sonos/const.py
+++ b/homeassistant/components/sonos/const.py
@@ -194,6 +194,7 @@ ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled"
SPEECH_DIALOG_LEVEL = "speech_dialog_level"
ATTR_DIALOG_LEVEL = "dialog_level"
ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum"
+ATTR_QUEUE_POSITION = "queue_position"
AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1)
AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 79a50ef4732..bf1dea71544 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -8,7 +8,8 @@
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco", "sonos_websocket"],
- "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"],
+ "quality_scale": "bronze",
+ "requirements": ["soco==0.30.12", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py
index 255daf22829..6abe5432371 100644
--- a/homeassistant/components/sonos/media_browser.py
+++ b/homeassistant/components/sonos/media_browser.py
@@ -378,7 +378,7 @@ async def root_payload(
children.extend(item.children)
else:
children.append(item)
- except media_source.BrowseError:
+ except BrowseError:
pass
if len(children) == 1:
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 0b30c820da3..a2719ec6ba9 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -17,7 +17,6 @@ from soco.core import (
from soco.data_structures import DidlFavorite, DidlMusicTrack
from soco.ms_data_structures import MusicServiceItem
from sonos_websocket.exception import SonosWebsocketError
-import voluptuous as vol
from homeassistant.components import media_source, spotify
from homeassistant.components.media_player import (
@@ -27,6 +26,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_ENQUEUE,
+ ATTR_MEDIA_EXTRA,
ATTR_MEDIA_TITLE,
BrowseMedia,
MediaPlayerDeviceClass,
@@ -40,21 +40,16 @@ from homeassistant.components.media_player import (
)
from homeassistant.components.plex import PLEX_URI_SCHEME
from homeassistant.components.plex.services import process_plex_payload
-from homeassistant.const import ATTR_TIME
-from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import (
- config_validation as cv,
- entity_platform,
- entity_registry as er,
- service,
-)
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import media_browser
from .const import (
+ ATTR_QUEUE_POSITION,
DOMAIN,
MEDIA_TYPE_DIRECTORY,
MEDIA_TYPES_TO_SONOS,
@@ -93,24 +88,6 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
-SERVICE_SNAPSHOT = "snapshot"
-SERVICE_RESTORE = "restore"
-SERVICE_SET_TIMER = "set_sleep_timer"
-SERVICE_CLEAR_TIMER = "clear_sleep_timer"
-SERVICE_UPDATE_ALARM = "update_alarm"
-SERVICE_PLAY_QUEUE = "play_queue"
-SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
-SERVICE_GET_QUEUE = "get_queue"
-
-ATTR_SLEEP_TIME = "sleep_time"
-ATTR_ALARM_ID = "alarm_id"
-ATTR_VOLUME = "volume"
-ATTR_ENABLED = "enabled"
-ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
-ATTR_MASTER = "master"
-ATTR_WITH_GROUP = "with_group"
-ATTR_QUEUE_POSITION = "queue_position"
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -118,7 +95,6 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
- platform = entity_platform.async_get_current_platform()
@callback
def async_create_entities(speaker: SonosSpeaker) -> None:
@@ -126,90 +102,10 @@ async def async_setup_entry(
_LOGGER.debug("Creating media_player on %s", speaker.zone_name)
async_add_entities([SonosMediaPlayerEntity(speaker, config_entry)])
- @service.verify_domain_control(hass, DOMAIN)
- async def async_service_handle(service_call: ServiceCall) -> None:
- """Handle dispatched services."""
- assert platform is not None
- entities = await platform.async_extract_from_service(service_call)
-
- if not entities:
- return
-
- speakers = []
- for entity in entities:
- assert isinstance(entity, SonosMediaPlayerEntity)
- speakers.append(entity.speaker)
-
- if service_call.service == SERVICE_SNAPSHOT:
- await SonosSpeaker.snapshot_multi(
- hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
- )
- elif service_call.service == SERVICE_RESTORE:
- await SonosSpeaker.restore_multi(
- hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
- )
-
config_entry.async_on_unload(
async_dispatcher_connect(hass, SONOS_CREATE_MEDIA_PLAYER, async_create_entities)
)
- join_unjoin_schema = cv.make_entity_service_schema(
- {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}
- )
-
- hass.services.async_register(
- DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
- )
-
- hass.services.async_register(
- DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
- )
-
- platform.async_register_entity_service(
- SERVICE_SET_TIMER,
- {
- vol.Required(ATTR_SLEEP_TIME): vol.All(
- vol.Coerce(int), vol.Range(min=0, max=86399)
- )
- },
- "set_sleep_timer",
- )
-
- platform.async_register_entity_service(
- SERVICE_CLEAR_TIMER, None, "clear_sleep_timer"
- )
-
- platform.async_register_entity_service(
- SERVICE_UPDATE_ALARM,
- {
- vol.Required(ATTR_ALARM_ID): cv.positive_int,
- vol.Optional(ATTR_TIME): cv.time,
- vol.Optional(ATTR_VOLUME): cv.small_float,
- vol.Optional(ATTR_ENABLED): cv.boolean,
- vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
- },
- "set_alarm",
- )
-
- platform.async_register_entity_service(
- SERVICE_PLAY_QUEUE,
- {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
- "play_queue",
- )
-
- platform.async_register_entity_service(
- SERVICE_REMOVE_FROM_QUEUE,
- {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
- "remove_from_queue",
- )
-
- platform.async_register_entity_service(
- SERVICE_GET_QUEUE,
- None,
- "get_queue",
- supports_response=SupportsResponse.ONLY,
- )
-
class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Representation of a Sonos entity."""
@@ -410,7 +306,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
@soco_error()
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
- self.soco.volume = int(volume * 100)
+ self.soco.volume = int(round(volume * 100))
@soco_error(UPNP_ERRORS_TO_IGNORE)
def set_shuffle(self, shuffle: bool) -> None:
@@ -643,26 +539,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
share_link = self.coordinator.share_link
if share_link.is_share_link(media_id):
- if enqueue == MediaPlayerEnqueue.ADD:
- share_link.add_share_link_to_queue(
- media_id, timeout=LONG_SERVICE_TIMEOUT
- )
- elif enqueue in (
- MediaPlayerEnqueue.NEXT,
- MediaPlayerEnqueue.PLAY,
- ):
- pos = (self.media.queue_position or 0) + 1
- new_pos = share_link.add_share_link_to_queue(
- media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
- )
- if enqueue == MediaPlayerEnqueue.PLAY:
- soco.play_from_queue(new_pos - 1)
- elif enqueue == MediaPlayerEnqueue.REPLACE:
- soco.clear_queue()
- share_link.add_share_link_to_queue(
- media_id, timeout=LONG_SERVICE_TIMEOUT
- )
- soco.play_from_queue(0)
+ title = kwargs.get(ATTR_MEDIA_EXTRA, {}).get("title", "")
+ self._play_media_sharelink(
+ soco=soco,
+ media_type=media_type,
+ media_id=media_id,
+ enqueue=enqueue,
+ title=title,
+ )
elif media_type == MEDIA_TYPE_DIRECTORY:
self._play_media_directory(
soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue
@@ -726,7 +610,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
def _play_media_queue(
self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue
- ):
+ ) -> None:
"""Manage adding, replacing, playing items onto the sonos queue."""
_LOGGER.debug(
"_play_media_queue item_id [%s] title [%s] enqueue [%s]",
@@ -755,7 +639,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue,
- ):
+ ) -> None:
"""Play a directory from a music library share."""
item = media_browser.get_media(self.media.library, media_id, media_type)
if not item:
@@ -768,6 +652,40 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
)
self._play_media_queue(soco, item, enqueue)
+ def _play_media_sharelink(
+ self,
+ soco: SoCo,
+ media_type: MediaType | str,
+ media_id: str,
+ enqueue: MediaPlayerEnqueue,
+ title: str,
+ ) -> None:
+ """Play a sharelink."""
+ share_link = self.coordinator.share_link
+ kwargs = {}
+ if title:
+ kwargs["dc_title"] = title
+ if enqueue == MediaPlayerEnqueue.ADD:
+ share_link.add_share_link_to_queue(
+ media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs
+ )
+ elif enqueue in (
+ MediaPlayerEnqueue.NEXT,
+ MediaPlayerEnqueue.PLAY,
+ ):
+ pos = (self.media.queue_position or 0) + 1
+ new_pos = share_link.add_share_link_to_queue(
+ media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT, **kwargs
+ )
+ if enqueue == MediaPlayerEnqueue.PLAY:
+ soco.play_from_queue(new_pos - 1)
+ elif enqueue == MediaPlayerEnqueue.REPLACE:
+ soco.clear_queue()
+ share_link.add_share_link_to_queue(
+ media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs
+ )
+ soco.play_from_queue(0)
+
@soco_error()
def set_sleep_timer(self, sleep_time: int) -> None:
"""Set the timer on the player."""
diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py
index 052a1d87967..fa38bf20c9f 100644
--- a/homeassistant/components/sonos/select.py
+++ b/homeassistant/components/sonos/select.py
@@ -59,9 +59,11 @@ async def async_setup_entry(
for select_data in SELECT_TYPES:
if select_data.speaker_model == speaker.model_name.upper():
if (
- state := getattr(speaker.soco, select_data.soco_attribute, None)
- ) is not None:
- setattr(speaker, select_data.speaker_attribute, state)
+ speaker.update_soco_int_attribute(
+ select_data.soco_attribute, select_data.speaker_attribute
+ )
+ is not None
+ ):
features.append(select_data)
return features
@@ -105,8 +107,9 @@ class SonosSelectEntity(SonosEntity, SelectEntity):
@soco_error()
def poll_state(self) -> None:
"""Poll the device for the current state."""
- state = getattr(self.soco, self.soco_attribute)
- setattr(self.speaker, self.speaker_attribute, state)
+ self.speaker.update_soco_int_attribute(
+ self.soco_attribute, self.speaker_attribute
+ )
@property
def current_option(self) -> str | None:
diff --git a/homeassistant/components/sonos/services.py b/homeassistant/components/sonos/services.py
new file mode 100644
index 00000000000..883835a7c86
--- /dev/null
+++ b/homeassistant/components/sonos/services.py
@@ -0,0 +1,143 @@
+"""Support to interface with Sonos players."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
+from homeassistant.const import ATTR_TIME
+from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
+from homeassistant.helpers import config_validation as cv, service
+from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES
+
+from .const import ATTR_QUEUE_POSITION, DOMAIN
+from .media_player import SonosMediaPlayerEntity
+from .speaker import SonosSpeaker
+
+SERVICE_SNAPSHOT = "snapshot"
+SERVICE_RESTORE = "restore"
+SERVICE_SET_TIMER = "set_sleep_timer"
+SERVICE_CLEAR_TIMER = "clear_sleep_timer"
+SERVICE_UPDATE_ALARM = "update_alarm"
+SERVICE_PLAY_QUEUE = "play_queue"
+SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
+SERVICE_GET_QUEUE = "get_queue"
+
+ATTR_SLEEP_TIME = "sleep_time"
+ATTR_ALARM_ID = "alarm_id"
+ATTR_VOLUME = "volume"
+ATTR_ENABLED = "enabled"
+ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
+ATTR_WITH_GROUP = "with_group"
+
+
+@callback
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Register Sonos services."""
+
+ @service.verify_domain_control(DOMAIN)
+ async def async_service_handle(service_call: ServiceCall) -> None:
+ """Handle dispatched services."""
+ platform_entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get(
+ (MEDIA_PLAYER_DOMAIN, DOMAIN), {}
+ )
+
+ entities = await service.async_extract_entities(
+ platform_entities.values(), service_call
+ )
+
+ if not entities:
+ return
+
+ speakers: list[SonosSpeaker] = []
+ for entity in entities:
+ assert isinstance(entity, SonosMediaPlayerEntity)
+ speakers.append(entity.speaker)
+
+ config_entry = speakers[0].config_entry # All speakers share the same entry
+
+ if service_call.service == SERVICE_SNAPSHOT:
+ await SonosSpeaker.snapshot_multi(
+ hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
+ )
+ elif service_call.service == SERVICE_RESTORE:
+ await SonosSpeaker.restore_multi(
+ hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
+ )
+
+ join_unjoin_schema = cv.make_entity_service_schema(
+ {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}
+ )
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
+ )
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_SET_TIMER,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={
+ vol.Required(ATTR_SLEEP_TIME): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=86399)
+ )
+ },
+ func="set_sleep_timer",
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_CLEAR_TIMER,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema=None,
+ func="clear_sleep_timer",
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_UPDATE_ALARM,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={
+ vol.Required(ATTR_ALARM_ID): cv.positive_int,
+ vol.Optional(ATTR_TIME): cv.time,
+ vol.Optional(ATTR_VOLUME): cv.small_float,
+ vol.Optional(ATTR_ENABLED): cv.boolean,
+ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
+ },
+ func="set_alarm",
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_PLAY_QUEUE,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
+ func="play_queue",
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_REMOVE_FROM_QUEUE,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema={vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
+ func="remove_from_queue",
+ )
+
+ service.async_register_platform_entity_service(
+ hass,
+ DOMAIN,
+ SERVICE_GET_QUEUE,
+ entity_domain=MEDIA_PLAYER_DOMAIN,
+ schema=None,
+ func="get_queue",
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml
index 89706428899..5d596c5679f 100644
--- a/homeassistant/components/sonos/services.yaml
+++ b/homeassistant/components/sonos/services.yaml
@@ -24,8 +24,9 @@ restore:
set_sleep_timer:
target:
- device:
+ entity:
integration: sonos
+ domain: media_player
fields:
sleep_time:
selector:
@@ -36,13 +37,15 @@ set_sleep_timer:
clear_sleep_timer:
target:
- device:
+ entity:
integration: sonos
+ domain: media_player
play_queue:
target:
- device:
+ entity:
integration: sonos
+ domain: media_player
fields:
queue_position:
selector:
@@ -53,8 +56,9 @@ play_queue:
remove_from_queue:
target:
- device:
+ entity:
integration: sonos
+ domain: media_player
fields:
queue_position:
selector:
@@ -71,8 +75,9 @@ get_queue:
update_alarm:
target:
- device:
+ entity:
integration: sonos
+ domain: media_player
fields:
alarm_id:
required: true
diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py
index 427f02f0479..c61f047d3e3 100644
--- a/homeassistant/components/sonos/speaker.py
+++ b/homeassistant/components/sonos/speaker.py
@@ -275,6 +275,29 @@ class SonosSpeaker:
"""Write states for associated SonosEntity instances."""
async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
+ def update_soco_int_attribute(
+ self, soco_attribute: str, speaker_attribute: str
+ ) -> int | None:
+ """Update an integer attribute from SoCo and set it on the speaker.
+
+ Returns the integer value if successful, otherwise None. Do not call from
+ async context as it is a blocking function.
+ """
+ value: int | None = None
+ if (state := getattr(self.soco, soco_attribute, None)) is None:
+ _LOGGER.error("Missing value for %s", speaker_attribute)
+ else:
+ try:
+ value = int(state)
+ except (TypeError, ValueError):
+ _LOGGER.error(
+ "Invalid value for %s %s",
+ speaker_attribute,
+ state,
+ )
+ setattr(self, speaker_attribute, value)
+ return value
+
#
# Properties
#
@@ -599,7 +622,12 @@ class SonosSpeaker:
for enum_var in (ATTR_DIALOG_LEVEL,):
if enum_var in variables:
- setattr(self, f"{enum_var}_enum", variables[enum_var])
+ try:
+ setattr(self, f"{enum_var}_enum", int(variables[enum_var]))
+ except ValueError:
+ _LOGGER.error(
+ "Invalid value for %s %s", enum_var, variables[enum_var]
+ )
self.async_write_entity_states()
diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py
index 33ed64be2bf..0fa1b0a5641 100644
--- a/homeassistant/components/sql/__init__.py
+++ b/homeassistant/components/sql/__init__.py
@@ -3,8 +3,8 @@
from __future__ import annotations
import logging
+from typing import Any
-import sqlparse
import voluptuous as vol
from homeassistant.components.recorder import CONF_DB_URL, get_instance
@@ -32,24 +32,18 @@ from homeassistant.helpers.trigger_template_entity import (
)
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS
-from .util import redact_credentials
+from .const import (
+ CONF_ADVANCED_OPTIONS,
+ CONF_COLUMN_NAME,
+ CONF_QUERY,
+ DOMAIN,
+ PLATFORMS,
+)
+from .util import redact_credentials, validate_sql_select
_LOGGER = logging.getLogger(__name__)
-def validate_sql_select(value: str) -> str:
- """Validate that value is a SQL SELECT query."""
- if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1:
- raise vol.Invalid("Multiple SQL queries are not supported")
- if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN":
- raise vol.Invalid("Invalid SQL query")
- if query_type != "SELECT":
- _LOGGER.debug("The SQL query %s is of type %s", query, query_type)
- raise vol.Invalid("Only SELECT queries allowed")
- return str(query[0])
-
-
QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_COLUMN_NAME): cv.string,
@@ -75,18 +69,6 @@ CONFIG_SCHEMA = vol.Schema(
)
-def remove_configured_db_url_if_not_needed(
- hass: HomeAssistant, entry: ConfigEntry
-) -> None:
- """Remove db url from config if it matches recorder database."""
- hass.config_entries.async_update_entry(
- entry,
- options={
- key: value for key, value in entry.options.items() if key != CONF_DB_URL
- },
- )
-
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up SQL from yaml config."""
if (conf := config.get(DOMAIN)) is None:
@@ -107,8 +89,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
redact_credentials(entry.options.get(CONF_DB_URL)),
redact_credentials(get_instance(hass).db_url),
)
- if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url:
- remove_configured_db_url_if_not_needed(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -119,3 +99,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload SQL config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Migrate old entry."""
+ _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
+
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if entry.version == 1:
+ old_options = {**entry.options}
+ new_data = {}
+ new_options: dict[str, Any] = {}
+
+ if (db_url := old_options.get(CONF_DB_URL)) and db_url != get_instance(
+ hass
+ ).db_url:
+ new_data[CONF_DB_URL] = db_url
+
+ new_options[CONF_COLUMN_NAME] = old_options.get(CONF_COLUMN_NAME)
+ new_options[CONF_QUERY] = old_options.get(CONF_QUERY)
+ new_options[CONF_ADVANCED_OPTIONS] = {}
+
+ for key in (
+ CONF_VALUE_TEMPLATE,
+ CONF_UNIT_OF_MEASUREMENT,
+ CONF_DEVICE_CLASS,
+ CONF_STATE_CLASS,
+ ):
+ if (value := old_options.get(key)) is not None:
+ new_options[CONF_ADVANCED_OPTIONS][key] = value
+
+ hass.config_entries.async_update_entry(
+ entry, data=new_data, options=new_options, version=2
+ )
+
+ _LOGGER.debug(
+ "Migration to version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py
index 37a6f9ef104..a614105d8bc 100644
--- a/homeassistant/components/sql/config_flow.py
+++ b/homeassistant/components/sql/config_flow.py
@@ -6,7 +6,7 @@ import logging
from typing import Any
import sqlalchemy
-from sqlalchemy.engine import Result
+from sqlalchemy.engine import Engine, Result
from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError
from sqlalchemy.orm import Session, scoped_session, sessionmaker
import sqlparse
@@ -32,9 +32,10 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import callback
+from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector
-from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
+from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import resolve_db_url
_LOGGER = logging.getLogger(__name__)
@@ -42,40 +43,38 @@ _LOGGER = logging.getLogger(__name__)
OPTIONS_SCHEMA: vol.Schema = vol.Schema(
{
- vol.Optional(
- CONF_DB_URL,
- ): selector.TextSelector(),
- vol.Required(
- CONF_COLUMN_NAME,
- ): selector.TextSelector(),
- vol.Required(
- CONF_QUERY,
- ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
- vol.Optional(
- CONF_UNIT_OF_MEASUREMENT,
- ): selector.TextSelector(),
- vol.Optional(
- CONF_VALUE_TEMPLATE,
- ): selector.TemplateSelector(),
- vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
- selector.SelectSelectorConfig(
- options=[
- cls.value
- for cls in SensorDeviceClass
- if cls != SensorDeviceClass.ENUM
- ],
- mode=selector.SelectSelectorMode.DROPDOWN,
- translation_key="device_class",
- sort=True,
- )
+ vol.Required(CONF_QUERY): selector.TextSelector(
+ selector.TextSelectorConfig(multiline=True)
),
- vol.Optional(CONF_STATE_CLASS): selector.SelectSelector(
- selector.SelectSelectorConfig(
- options=[cls.value for cls in SensorStateClass],
- mode=selector.SelectSelectorMode.DROPDOWN,
- translation_key="state_class",
- sort=True,
- )
+ vol.Required(CONF_COLUMN_NAME): selector.TextSelector(),
+ vol.Required(CONF_ADVANCED_OPTIONS): section(
+ vol.Schema(
+ {
+ vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector(),
+ vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[
+ cls.value
+ for cls in SensorDeviceClass
+ if cls != SensorDeviceClass.ENUM
+ ],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="device_class",
+ sort=True,
+ )
+ ),
+ vol.Optional(CONF_STATE_CLASS): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[cls.value for cls in SensorStateClass],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="state_class",
+ sort=True,
+ )
+ ),
+ }
+ ),
+ {"collapsed": True},
),
}
)
@@ -83,8 +82,9 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema(
CONFIG_SCHEMA: vol.Schema = vol.Schema(
{
vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(),
+ vol.Optional(CONF_DB_URL): selector.TextSelector(),
}
-).extend(OPTIONS_SCHEMA.schema)
+)
def validate_sql_select(value: str) -> str:
@@ -99,6 +99,31 @@ def validate_sql_select(value: str) -> str:
return str(query[0])
+def validate_db_connection(db_url: str) -> bool:
+ """Validate db connection."""
+
+ engine: Engine | None = None
+ sess: Session | None = None
+ try:
+ engine = sqlalchemy.create_engine(db_url, future=True)
+ sessmaker = scoped_session(sessionmaker(bind=engine, future=True))
+ sess = sessmaker()
+ sess.execute(sqlalchemy.text("select 1 as value"))
+ except SQLAlchemyError as error:
+ _LOGGER.debug("Execution error %s", error)
+ if sess:
+ sess.close()
+ if engine:
+ engine.dispose()
+ raise
+
+ if sess:
+ sess.close()
+ engine.dispose()
+
+ return True
+
+
def validate_query(db_url: str, query: str, column: str) -> bool:
"""Validate SQL query."""
@@ -136,7 +161,9 @@ def validate_query(db_url: str, query: str, column: str) -> bool:
class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SQL integration."""
- VERSION = 1
+ VERSION = 2
+
+ data: dict[str, Any]
@staticmethod
@callback
@@ -151,17 +178,46 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the user step."""
errors = {}
- description_placeholders = {}
if user_input is not None:
db_url = user_input.get(CONF_DB_URL)
+
+ try:
+ db_url_for_validation = resolve_db_url(self.hass, db_url)
+ await self.hass.async_add_executor_job(
+ validate_db_connection, db_url_for_validation
+ )
+ except SQLAlchemyError:
+ errors["db_url"] = "db_url_invalid"
+
+ if not errors:
+ self.data = {CONF_NAME: user_input[CONF_NAME]}
+ if db_url and db_url_for_validation != get_instance(self.hass).db_url:
+ self.data[CONF_DB_URL] = db_url
+ return await self.async_step_options()
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input),
+ errors=errors,
+ )
+
+ async def async_step_options(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step."""
+ errors = {}
+ description_placeholders = {}
+
+ if user_input is not None:
query = user_input[CONF_QUERY]
column = user_input[CONF_COLUMN_NAME]
- db_url_for_validation = None
try:
query = validate_sql_select(query)
- db_url_for_validation = resolve_db_url(self.hass, db_url)
+ db_url_for_validation = resolve_db_url(
+ self.hass, self.data.get(CONF_DB_URL)
+ )
await self.hass.async_add_executor_job(
validate_query, db_url_for_validation, query, column
)
@@ -178,32 +234,25 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Invalid query: %s", err)
errors["query"] = "query_invalid"
- options = {
- CONF_QUERY: query,
- CONF_COLUMN_NAME: column,
- CONF_NAME: user_input[CONF_NAME],
+ mod_advanced_options = {
+ k: v
+ for k, v in user_input[CONF_ADVANCED_OPTIONS].items()
+ if v is not None
}
- if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT):
- options[CONF_UNIT_OF_MEASUREMENT] = uom
- if value_template := user_input.get(CONF_VALUE_TEMPLATE):
- options[CONF_VALUE_TEMPLATE] = value_template
- if device_class := user_input.get(CONF_DEVICE_CLASS):
- options[CONF_DEVICE_CLASS] = device_class
- if state_class := user_input.get(CONF_STATE_CLASS):
- options[CONF_STATE_CLASS] = state_class
- if db_url_for_validation != get_instance(self.hass).db_url:
- options[CONF_DB_URL] = db_url_for_validation
+ user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options
if not errors:
+ name = self.data[CONF_NAME]
+ self.data.pop(CONF_NAME)
return self.async_create_entry(
- title=user_input[CONF_NAME],
- data={},
- options=options,
+ title=name,
+ data=self.data,
+ options=user_input,
)
return self.async_show_form(
- step_id="user",
- data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input),
+ step_id="options",
+ data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, user_input),
errors=errors,
description_placeholders=description_placeholders,
)
@@ -220,10 +269,9 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload):
description_placeholders = {}
if user_input is not None:
- db_url = user_input.get(CONF_DB_URL)
+ db_url = self.config_entry.data.get(CONF_DB_URL)
query = user_input[CONF_QUERY]
column = user_input[CONF_COLUMN_NAME]
- name = self.config_entry.options.get(CONF_NAME, self.config_entry.title)
try:
query = validate_sql_select(query)
@@ -252,24 +300,15 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload):
recorder_db,
)
- options = {
- CONF_QUERY: query,
- CONF_COLUMN_NAME: column,
- CONF_NAME: name,
+ mod_advanced_options = {
+ k: v
+ for k, v in user_input[CONF_ADVANCED_OPTIONS].items()
+ if v is not None
}
- if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT):
- options[CONF_UNIT_OF_MEASUREMENT] = uom
- if value_template := user_input.get(CONF_VALUE_TEMPLATE):
- options[CONF_VALUE_TEMPLATE] = value_template
- if device_class := user_input.get(CONF_DEVICE_CLASS):
- options[CONF_DEVICE_CLASS] = device_class
- if state_class := user_input.get(CONF_STATE_CLASS):
- options[CONF_STATE_CLASS] = state_class
- if db_url_for_validation != get_instance(self.hass).db_url:
- options[CONF_DB_URL] = db_url_for_validation
+ user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options
return self.async_create_entry(
- data=options,
+ data=user_input,
)
return self.async_show_form(
diff --git a/homeassistant/components/sql/const.py b/homeassistant/components/sql/const.py
index d8d13ab1699..20e54c52abf 100644
--- a/homeassistant/components/sql/const.py
+++ b/homeassistant/components/sql/const.py
@@ -9,4 +9,5 @@ PLATFORMS = [Platform.SENSOR]
CONF_COLUMN_NAME = "column"
CONF_QUERY = "query"
+CONF_ADVANCED_OPTIONS = "advanced_options"
DB_URL_RE = re.compile("//.*:.*@")
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
index 8c0ba81d6d2..508365b5c0d 100644
--- a/homeassistant/components/sql/sensor.py
+++ b/homeassistant/components/sql/sensor.py
@@ -7,19 +7,11 @@ import decimal
import logging
from typing import Any
-import sqlalchemy
-from sqlalchemy import lambda_stmt
from sqlalchemy.engine import Result
from sqlalchemy.exc import SQLAlchemyError
-from sqlalchemy.orm import Session, scoped_session, sessionmaker
-from sqlalchemy.sql.lambdas import StatementLambdaElement
-from sqlalchemy.util import LRUCache
+from sqlalchemy.orm import scoped_session
-from homeassistant.components.recorder import (
- CONF_DB_URL,
- SupportedDialect,
- get_instance,
-)
+from homeassistant.components.recorder import CONF_DB_URL, get_instance
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -29,17 +21,16 @@ from homeassistant.const import (
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_STOP,
MATCH_ALL,
)
-from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
@@ -49,14 +40,17 @@ from homeassistant.helpers.trigger_template_entity import (
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
-from .models import SQLData
-from .util import redact_credentials, resolve_db_url
+from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
+from .util import (
+ async_create_sessionmaker,
+ generate_lambda_stmt,
+ redact_credentials,
+ resolve_db_url,
+ validate_query,
+)
_LOGGER = logging.getLogger(__name__)
-_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000)
-
TRIGGER_ENTITY_OPTIONS = (
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
@@ -76,6 +70,15 @@ async def async_setup_platform(
) -> None:
"""Set up the SQL sensor from yaml."""
if (conf := discovery_info) is None:
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "sensor_platform_yaml_not_supported",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="platform_yaml_not_supported",
+ learn_more_url="https://www.home-assistant.io/integrations/sql/",
+ )
return
name: Template = conf[CONF_NAME]
@@ -111,10 +114,10 @@ async def async_setup_entry(
) -> None:
"""Set up the SQL sensor from config entry."""
- db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL))
- name: str = entry.options[CONF_NAME]
+ db_url: str = resolve_db_url(hass, entry.data.get(CONF_DB_URL))
+ name: str = entry.title
query_str: str = entry.options[CONF_QUERY]
- template: str | None = entry.options.get(CONF_VALUE_TEMPLATE)
+ template: str | None = entry.options[CONF_ADVANCED_OPTIONS].get(CONF_VALUE_TEMPLATE)
column_name: str = entry.options[CONF_COLUMN_NAME]
value_template: ValueTemplate | None = None
@@ -128,9 +131,9 @@ async def async_setup_entry(
name_template = Template(name, hass)
trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id}
for key in TRIGGER_ENTITY_OPTIONS:
- if key not in entry.options:
+ if key not in entry.options[CONF_ADVANCED_OPTIONS]:
continue
- trigger_entity_config[key] = entry.options[key]
+ trigger_entity_config[key] = entry.options[CONF_ADVANCED_OPTIONS][key]
await async_setup_sensor(
hass,
@@ -145,36 +148,6 @@ async def async_setup_entry(
)
-@callback
-def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData:
- """Get or initialize domain data."""
- if DOMAIN in hass.data:
- sql_data: SQLData = hass.data[DOMAIN]
- return sql_data
-
- session_makers_by_db_url: dict[str, scoped_session] = {}
-
- #
- # Ensure we dispose of all engines at shutdown
- # to avoid unclean disconnects
- #
- # Shutdown all sessions in the executor since they will
- # do blocking I/O
- #
- def _shutdown_db_engines(event: Event) -> None:
- """Shutdown all database engines."""
- for sessmaker in session_makers_by_db_url.values():
- sessmaker.connection().engine.dispose()
-
- cancel_shutdown = hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines
- )
-
- sql_data = SQLData(cancel_shutdown, session_makers_by_db_url)
- hass.data[DOMAIN] = sql_data
- return sql_data
-
-
async def async_setup_sensor(
hass: HomeAssistant,
trigger_entity_config: ConfigType,
@@ -187,70 +160,16 @@ async def async_setup_sensor(
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SQL sensor."""
- try:
- instance = get_instance(hass)
- except KeyError: # No recorder loaded
- uses_recorder_db = False
- else:
- uses_recorder_db = db_url == instance.db_url
- sessmaker: scoped_session | None
- sql_data = _async_get_or_init_domain_data(hass)
- use_database_executor = False
- if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE:
- use_database_executor = True
- assert instance.engine is not None
- sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True))
- # For other databases we need to create a new engine since
- # we want the connection to use the default timezone and these
- # database engines will use QueuePool as its only sqlite that
- # needs our custom pool. If there is already a session maker
- # for this db_url we can use that so we do not create a new engine
- # for every sensor.
- elif db_url in sql_data.session_makers_by_db_url:
- sessmaker = sql_data.session_makers_by_db_url[db_url]
- elif sessmaker := await hass.async_add_executor_job(
- _validate_and_get_session_maker_for_db_url, db_url
- ):
- sql_data.session_makers_by_db_url[db_url] = sessmaker
- else:
+ (
+ sessmaker,
+ uses_recorder_db,
+ use_database_executor,
+ ) = await async_create_sessionmaker(hass, db_url)
+ if sessmaker is None:
return
+ validate_query(hass, query_str, uses_recorder_db, unique_id)
upper_query = query_str.upper()
- if uses_recorder_db:
- redacted_query = redact_credentials(query_str)
-
- issue_key = unique_id if unique_id else redacted_query
- # If the query has a unique id and they fix it we can dismiss the issue
- # but if it doesn't have a unique id they have to ignore it instead
-
- if (
- "ENTITY_ID," in upper_query or "ENTITY_ID " in upper_query
- ) and "STATES_META" not in upper_query:
- _LOGGER.error(
- "The query `%s` contains the keyword `entity_id` but does not "
- "reference the `states_meta` table. This will cause a full table "
- "scan and database instability. Please check the documentation and use "
- "`states_meta.entity_id` instead",
- redacted_query,
- )
-
- ir.async_create_issue(
- hass,
- DOMAIN,
- f"entity_id_query_does_full_table_scan_{issue_key}",
- translation_key="entity_id_query_does_full_table_scan",
- translation_placeholders={"query": redacted_query},
- is_fixable=False,
- severity=ir.IssueSeverity.ERROR,
- )
- raise ValueError(
- "Query contains entity_id but does not reference states_meta"
- )
-
- ir.async_delete_issue(
- hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}"
- )
-
# MSSQL uses TOP and not LIMIT
if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query):
if "mssql" in db_url:
@@ -273,39 +192,6 @@ async def async_setup_sensor(
)
-def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None:
- """Validate the db_url and return a session maker.
-
- This does I/O and should be run in the executor.
- """
- sess: Session | None = None
- try:
- engine = sqlalchemy.create_engine(db_url, future=True)
- sessmaker = scoped_session(sessionmaker(bind=engine, future=True))
- # Run a dummy query just to test the db_url
- sess = sessmaker()
- sess.execute(sqlalchemy.text("SELECT 1;"))
-
- except SQLAlchemyError as err:
- _LOGGER.error(
- "Couldn't connect using %s DB_URL: %s",
- redact_credentials(db_url),
- redact_credentials(str(err)),
- )
- return None
- else:
- return sessmaker
- finally:
- if sess:
- sess.close()
-
-
-def _generate_lambda_stmt(query: str) -> StatementLambdaElement:
- """Generate the lambda statement."""
- text = sqlalchemy.text(query)
- return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE)
-
-
class SQLSensor(ManualTriggerSensorEntity):
"""Representation of an SQL sensor."""
@@ -329,7 +215,7 @@ class SQLSensor(ManualTriggerSensorEntity):
self.sessionmaker = sessmaker
self._attr_extra_state_attributes = {}
self._use_database_executor = use_database_executor
- self._lambda_stmt = _generate_lambda_stmt(query)
+ self._lambda_stmt = generate_lambda_stmt(query)
if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)):
self._attr_name = None
self._attr_has_entity_name = True
diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json
index cbc0deda96a..ae49dac049b 100644
--- a/homeassistant/components/sql/strings.json
+++ b/homeassistant/components/sql/strings.json
@@ -14,23 +14,39 @@
"user": {
"data": {
"db_url": "Database URL",
- "name": "[%key:common::config_flow::data::name%]",
- "query": "Select query",
- "column": "Column",
- "unit_of_measurement": "Unit of measurement",
- "value_template": "Value template",
- "device_class": "Device class",
- "state_class": "State class"
+ "name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"db_url": "Leave empty to use Home Assistant Recorder database",
- "name": "Name that will be used for config entry and also the sensor",
+ "name": "Name that will be used for config entry and also the sensor"
+ }
+ },
+ "options": {
+ "data": {
+ "query": "Select query",
+ "column": "Column"
+ },
+ "data_description": {
"query": "Query to run, needs to start with 'SELECT'",
- "column": "Column for returned query to present as state",
- "unit_of_measurement": "The unit of measurement for the sensor (optional)",
- "value_template": "Template to extract a value from the payload (optional)",
- "device_class": "The type/class of the sensor to set the icon in the frontend",
- "state_class": "The state class of the sensor"
+ "column": "Column for returned query to present as state"
+ },
+ "sections": {
+ "advanced_options": {
+ "name": "Advanced options",
+ "description": "Provide additional configuration to the sensor",
+ "data": {
+ "unit_of_measurement": "Unit of measurement",
+ "value_template": "Value template",
+ "device_class": "Device class",
+ "state_class": "State class"
+ },
+ "data_description": {
+ "unit_of_measurement": "The unit of measurement for the sensor (optional)",
+ "value_template": "Template to extract a value from the payload (optional)",
+ "device_class": "The type/class of the sensor to set the icon in the frontend",
+ "state_class": "The state class of the sensor"
+ }
+ }
}
}
}
@@ -39,24 +55,30 @@
"step": {
"init": {
"data": {
- "db_url": "[%key:component::sql::config::step::user::data::db_url%]",
- "name": "[%key:common::config_flow::data::name%]",
- "query": "[%key:component::sql::config::step::user::data::query%]",
- "column": "[%key:component::sql::config::step::user::data::column%]",
- "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]",
- "value_template": "[%key:component::sql::config::step::user::data::value_template%]",
- "device_class": "[%key:component::sql::config::step::user::data::device_class%]",
- "state_class": "[%key:component::sql::config::step::user::data::state_class%]"
+ "query": "[%key:component::sql::config::step::options::data::query%]",
+ "column": "[%key:component::sql::config::step::options::data::column%]"
},
"data_description": {
- "db_url": "[%key:component::sql::config::step::user::data_description::db_url%]",
- "name": "[%key:component::sql::config::step::user::data_description::name%]",
- "query": "[%key:component::sql::config::step::user::data_description::query%]",
- "column": "[%key:component::sql::config::step::user::data_description::column%]",
- "unit_of_measurement": "[%key:component::sql::config::step::user::data_description::unit_of_measurement%]",
- "value_template": "[%key:component::sql::config::step::user::data_description::value_template%]",
- "device_class": "[%key:component::sql::config::step::user::data_description::device_class%]",
- "state_class": "[%key:component::sql::config::step::user::data_description::state_class%]"
+ "query": "[%key:component::sql::config::step::options::data_description::query%]",
+ "column": "[%key:component::sql::config::step::options::data_description::column%]"
+ },
+ "sections": {
+ "advanced_options": {
+ "name": "[%key:component::sql::config::step::options::sections::advanced_options::name%]",
+ "description": "[%key:component::sql::config::step::options::sections::advanced_options::name%]",
+ "data": {
+ "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data::unit_of_measurement%]",
+ "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data::value_template%]",
+ "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::device_class%]",
+ "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::state_class%]"
+ },
+ "data_description": {
+ "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::unit_of_measurement%]",
+ "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::value_template%]",
+ "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::device_class%]",
+ "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::state_class%]"
+ }
+ }
}
}
},
@@ -103,6 +125,7 @@
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
@@ -143,6 +166,10 @@
"entity_id_query_does_full_table_scan": {
"title": "SQL query does full table scan",
"description": "The query `{query}` contains the keyword `entity_id` but does not reference the `states_meta` table. This will cause a full table scan and database instability. Please check the documentation and use `states_meta.entity_id` instead."
+ },
+ "platform_yaml_not_supported": {
+ "title": "Platform YAML is not supported in SQL",
+ "description": "Platform YAML setup is not supported.\nChange from configuring it in the `sensor:` key to using the `sql:` key directly in configuration.yaml.\nTo see the detailed documentation, select Learn more."
}
}
}
diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py
index 48fb53820ff..0200a83c9e8 100644
--- a/homeassistant/components/sql/util.py
+++ b/homeassistant/components/sql/util.py
@@ -4,13 +4,27 @@ from __future__ import annotations
import logging
-from homeassistant.components.recorder import get_instance
-from homeassistant.core import HomeAssistant
+import sqlalchemy
+from sqlalchemy import lambda_stmt
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.orm import Session, scoped_session, sessionmaker
+from sqlalchemy.sql.lambdas import StatementLambdaElement
+from sqlalchemy.util import LRUCache
+import sqlparse
+import voluptuous as vol
-from .const import DB_URL_RE
+from homeassistant.components.recorder import SupportedDialect, get_instance
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+
+from .const import DB_URL_RE, DOMAIN
+from .models import SQLData
_LOGGER = logging.getLogger(__name__)
+_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000)
+
def redact_credentials(data: str | None) -> str:
"""Redact credentials from string data."""
@@ -25,3 +39,187 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
if db_url and not db_url.isspace():
return db_url
return get_instance(hass).db_url
+
+
+def validate_sql_select(value: str) -> str:
+ """Validate that value is a SQL SELECT query."""
+ if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1:
+ raise vol.Invalid("Multiple SQL queries are not supported")
+ if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN":
+ raise vol.Invalid("Invalid SQL query")
+ if query_type != "SELECT":
+ _LOGGER.debug("The SQL query %s is of type %s", query, query_type)
+ raise vol.Invalid("Only SELECT queries allowed")
+ return str(query[0])
+
+
+async def async_create_sessionmaker(
+ hass: HomeAssistant, db_url: str
+) -> tuple[scoped_session | None, bool, bool]:
+ """Create a session maker for the given db_url.
+
+ This function gets or creates a SQLAlchemy `scoped_session` for the given
+ db_url. It reuses existing connections where possible and handles the special
+ case for the default recorder's database to use the correct executor.
+
+ Args:
+ hass: The Home Assistant instance.
+ db_url: The database URL to connect to.
+
+ Returns:
+ A tuple containing the following items:
+ - (scoped_session | None): The SQLAlchemy session maker for executing
+ queries. This is `None` if a connection to the database could not
+ be established.
+ - (bool): A flag indicating if the query is against the recorder
+ database.
+ - (bool): A flag indicating if the dedicated recorder database
+ executor should be used.
+
+ """
+ try:
+ instance = get_instance(hass)
+ except KeyError: # No recorder loaded
+ uses_recorder_db = False
+ else:
+ uses_recorder_db = db_url == instance.db_url
+ sessmaker: scoped_session | None
+ sql_data = _async_get_or_init_domain_data(hass)
+ use_database_executor = False
+ if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE:
+ use_database_executor = True
+ assert instance.engine is not None
+ sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True))
+ # For other databases we need to create a new engine since
+ # we want the connection to use the default timezone and these
+ # database engines will use QueuePool as its only sqlite that
+ # needs our custom pool. If there is already a session maker
+ # for this db_url we can use that so we do not create a new engine
+ # for every sensor.
+ elif db_url in sql_data.session_makers_by_db_url:
+ sessmaker = sql_data.session_makers_by_db_url[db_url]
+ elif sessmaker := await hass.async_add_executor_job(
+ _validate_and_get_session_maker_for_db_url, db_url
+ ):
+ sql_data.session_makers_by_db_url[db_url] = sessmaker
+ else:
+ return (None, uses_recorder_db, use_database_executor)
+
+ return (sessmaker, uses_recorder_db, use_database_executor)
+
+
+def validate_query(
+ hass: HomeAssistant,
+ query_str: str,
+ uses_recorder_db: bool,
+ unique_id: str | None = None,
+) -> None:
+ """Validate the query against common performance issues.
+
+ Args:
+ hass: The Home Assistant instance.
+ query_str: The SQL query string to be validated.
+ uses_recorder_db: A boolean indicating if the query is against the recorder database.
+ unique_id: The unique ID of the entity, used for creating issue registry keys.
+
+ Raises:
+ ValueError: If the query uses `entity_id` without referencing `states_meta`.
+
+ """
+ if not uses_recorder_db:
+ return
+ redacted_query = redact_credentials(query_str)
+
+ issue_key = unique_id if unique_id else redacted_query
+ # If the query has a unique id and they fix it we can dismiss the issue
+ # but if it doesn't have a unique id they have to ignore it instead
+
+ upper_query = query_str.upper()
+ if (
+ "ENTITY_ID," in upper_query or "ENTITY_ID " in upper_query
+ ) and "STATES_META" not in upper_query:
+ _LOGGER.error(
+ "The query `%s` contains the keyword `entity_id` but does not "
+ "reference the `states_meta` table. This will cause a full table "
+ "scan and database instability. Please check the documentation and use "
+ "`states_meta.entity_id` instead",
+ redacted_query,
+ )
+
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"entity_id_query_does_full_table_scan_{issue_key}",
+ translation_key="entity_id_query_does_full_table_scan",
+ translation_placeholders={"query": redacted_query},
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ )
+ raise ValueError("Query contains entity_id but does not reference states_meta")
+
+ ir.async_delete_issue(
+ hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}"
+ )
+
+
+@callback
+def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData:
+ """Get or initialize domain data."""
+ if DOMAIN in hass.data:
+ sql_data: SQLData = hass.data[DOMAIN]
+ return sql_data
+
+ session_makers_by_db_url: dict[str, scoped_session] = {}
+
+ #
+ # Ensure we dispose of all engines at shutdown
+ # to avoid unclean disconnects
+ #
+ # Shutdown all sessions in the executor since they will
+ # do blocking I/O
+ #
+ def _shutdown_db_engines(event: Event) -> None:
+ """Shutdown all database engines."""
+ for sessmaker in session_makers_by_db_url.values():
+ sessmaker.connection().engine.dispose()
+
+ cancel_shutdown = hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines
+ )
+
+ sql_data = SQLData(cancel_shutdown, session_makers_by_db_url)
+ hass.data[DOMAIN] = sql_data
+ return sql_data
+
+
+def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None:
+ """Validate the db_url and return a session maker.
+
+ This does I/O and should be run in the executor.
+ """
+ sess: Session | None = None
+ try:
+ engine = sqlalchemy.create_engine(db_url, future=True)
+ sessmaker = scoped_session(sessionmaker(bind=engine, future=True))
+ # Run a dummy query just to test the db_url
+ sess = sessmaker()
+ sess.execute(sqlalchemy.text("SELECT 1;"))
+
+ except SQLAlchemyError as err:
+ _LOGGER.error(
+ "Couldn't connect using %s DB_URL: %s",
+ redact_credentials(db_url),
+ redact_credentials(str(err)),
+ )
+ return None
+ else:
+ return sessmaker
+ finally:
+ if sess:
+ sess.close()
+
+
+def generate_lambda_stmt(query: str) -> StatementLambdaElement:
+ """Generate the lambda statement."""
+ text = sqlalchemy.text(query)
+ return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE)
diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py
index 2bd845923fc..c7411e935df 100644
--- a/homeassistant/components/squeezebox/__init__.py
+++ b/homeassistant/components/squeezebox/__init__.py
@@ -1,5 +1,6 @@
"""The Squeezebox integration."""
+import asyncio
from asyncio import timeout
from dataclasses import dataclass, field
from datetime import datetime
@@ -31,11 +32,11 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
+from homeassistant.util.hass_dict import HassKey
from .const import (
CONF_HTTPS,
DISCOVERY_INTERVAL,
- DISCOVERY_TASK,
DOMAIN,
SERVER_MANUFACTURER,
SERVER_MODEL,
@@ -64,6 +65,8 @@ PLATFORMS = [
Platform.UPDATE,
]
+SQUEEZEBOX_HASS_DATA: HassKey[asyncio.Task] = HassKey(DOMAIN)
+
@dataclass
class SqueezeboxData:
@@ -240,7 +243,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry)
current_entries = hass.config_entries.async_entries(DOMAIN)
if len(current_entries) == 1 and current_entries[0] == entry:
_LOGGER.debug("Stopping server discovery task")
- hass.data[DOMAIN][DISCOVERY_TASK].cancel()
- hass.data[DOMAIN].pop(DISCOVERY_TASK)
+ hass.data[SQUEEZEBOX_HASS_DATA].cancel()
+ hass.data.pop(SQUEEZEBOX_HASS_DATA)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py
index cebd4fcb04f..2ca9d6f058c 100644
--- a/homeassistant/components/squeezebox/browse_media.py
+++ b/homeassistant/components/squeezebox/browse_media.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any, cast
from pysqueezebox import Player
@@ -14,7 +14,6 @@ from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
- MediaPlayerEntity,
MediaType,
)
from homeassistant.core import HomeAssistant
@@ -22,6 +21,9 @@ from homeassistant.helpers.network import is_internal_request
from .const import DOMAIN, UNPLAYABLE_TYPES
+if TYPE_CHECKING:
+ from .media_player import SqueezeBoxMediaPlayerEntity
+
_LOGGER = logging.getLogger(__name__)
LIBRARY = [
@@ -116,6 +118,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
MediaType.APPS: MediaType.APP,
MediaType.APP: MediaType.TRACK,
"favorite": None,
+ "track": MediaType.TRACK,
}
@@ -243,34 +246,44 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia:
def _get_item_thumbnail(
item: dict[str, Any],
player: Player,
- entity: MediaPlayerEntity,
+ entity: SqueezeBoxMediaPlayerEntity,
item_type: str | MediaType | None,
search_type: str,
internal_request: bool,
+ known_apps_radios: set[str],
) -> str | None:
"""Construct path to thumbnail image."""
- item_thumbnail: str | None = None
+
track_id = item.get("artwork_track_id") or (
- item.get("id") if item_type == "track" else None
+ item.get("id")
+ if item_type == "track"
+ and search_type not in known_apps_radios | {"apps", "radios"}
+ else None
)
if track_id:
if internal_request:
- item_thumbnail = player.generate_image_url_from_track_id(track_id)
- elif item_type is not None:
- item_thumbnail = entity.get_browse_image_url(
- item_type, item["id"], track_id
- )
+ return cast(str, player.generate_image_url_from_track_id(track_id))
+ if item_type is not None:
+ return entity.get_browse_image_url(item_type, item["id"], track_id)
- elif search_type in ["apps", "radios"]:
- item_thumbnail = player.generate_image_url(item["icon"])
- if item_thumbnail is None:
- item_thumbnail = item.get("image_url") # will not be proxied by HA
- return item_thumbnail
+ url = None
+ content_type = item_type or "unknown"
+
+ if search_type in ["apps", "radios"]:
+ url = cast(str, player.generate_image_url(item["icon"]))
+ elif image_url := item.get("image_url"):
+ url = image_url
+
+ if internal_request or not url:
+ return url
+
+ synthetic_id = entity.get_synthetic_id_and_cache_url(url)
+ return entity.get_browse_image_url(content_type, "synthetic", synthetic_id)
async def build_item_response(
- entity: MediaPlayerEntity,
+ entity: SqueezeBoxMediaPlayerEntity,
player: Player,
payload: dict[str, str | None],
browse_limit: int,
@@ -356,6 +369,7 @@ async def build_item_response(
item_type=item_type,
search_type=search_type,
internal_request=internal_request,
+ known_apps_radios=browse_data.known_apps_radios,
)
children.append(child_media)
@@ -422,7 +436,7 @@ async def library_payload(
)
)
- with contextlib.suppress(media_source.BrowseError):
+ with contextlib.suppress(BrowseError):
browse = await media_source.async_browse_media(
hass, None, content_filter=media_source_content_filter
)
diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py
index 091ef4d1bbd..b61d28943cf 100644
--- a/homeassistant/components/squeezebox/const.py
+++ b/homeassistant/components/squeezebox/const.py
@@ -1,7 +1,6 @@
"""Constants for the Squeezebox component."""
CONF_HTTPS = "https"
-DISCOVERY_TASK = "discovery_task"
DOMAIN = "squeezebox"
DEFAULT_PORT = 9000
PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index a857602a584..0b9b54a1dcd 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -8,6 +8,7 @@ import json
import logging
from typing import TYPE_CHECKING, Any, cast
+from lru import LRU
from pysqueezebox import Server, async_discover
import voluptuous as vol
@@ -43,7 +44,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.start import async_at_start
from homeassistant.util.dt import utcnow
+from homeassistant.util.ulid import ulid_now
+from . import SQUEEZEBOX_HASS_DATA
from .browse_media import (
BrowseData,
build_item_response,
@@ -58,7 +61,6 @@ from .const import (
CONF_VOLUME_STEP,
DEFAULT_BROWSE_LIMIT,
DEFAULT_VOLUME_STEP,
- DISCOVERY_TASK,
DOMAIN,
SERVER_MANUFACTURER,
SERVER_MODEL,
@@ -110,12 +112,10 @@ async def start_server_discovery(hass: HomeAssistant) -> None:
},
)
- hass.data.setdefault(DOMAIN, {})
- if DISCOVERY_TASK not in hass.data[DOMAIN]:
+ if not hass.data.get(SQUEEZEBOX_HASS_DATA):
_LOGGER.debug("Adding server discovery task for squeezebox")
- hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task(
- async_discover(_discovered_server),
- name="squeezebox server discovery",
+ hass.data[SQUEEZEBOX_HASS_DATA] = hass.async_create_background_task(
+ async_discover(_discovered_server), name="squeezebox server discovery"
)
@@ -262,6 +262,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
self._previous_media_position = 0
self._attr_unique_id = format_mac(self._player.player_id)
self._browse_data = BrowseData()
+ self._synthetic_media_browser_thumbnail_items: LRU[str, str] = LRU(5000)
@callback
def _handle_coordinator_update(self) -> None:
@@ -607,7 +608,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
_media_content_type_list = (
query.media_content_type.lower().replace(", ", ",").split(",")
if query.media_content_type
- else ["albums", "tracks", "artists", "genres"]
+ else ["albums", "tracks", "artists", "genres", "playlists"]
)
if query.media_content_type and set(_media_content_type_list).difference(
@@ -744,6 +745,17 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
await self._player.async_unsync()
await self.coordinator.async_refresh()
+ def get_synthetic_id_and_cache_url(self, url: str) -> str:
+ """Cache a thumbnail URL and return a synthetic ID.
+
+ This enables us to proxy thumbnails for apps and favorites, as those do not have IDs.
+ """
+ synthetic_id = f"s_{ulid_now()}"
+
+ self._synthetic_media_browser_thumbnail_items[synthetic_id] = url
+
+ return synthetic_id
+
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
@@ -787,11 +799,21 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
media_image_id: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Get album art from Squeezebox server."""
- if media_image_id:
- image_url = self._player.generate_image_url_from_track_id(media_image_id)
- result = await self._async_fetch_image(image_url)
- if result == (None, None):
- _LOGGER.debug("Error retrieving proxied album art from %s", image_url)
- return result
+ if not media_image_id:
+ return (None, None)
- return (None, None)
+ if media_content_id == "synthetic":
+ image_url = self._synthetic_media_browser_thumbnail_items.get(
+ media_image_id
+ )
+
+ if image_url is None:
+ _LOGGER.debug("Synthetic ID %s not found in cache", media_image_id)
+ return (None, None)
+ else:
+ image_url = self._player.generate_image_url_from_track_id(media_image_id)
+
+ result = await self._async_fetch_image(image_url)
+ if result == (None, None):
+ _LOGGER.debug("Error retrieving proxied album art from %s", image_url)
+ return result
diff --git a/homeassistant/components/squeezebox/switch.py b/homeassistant/components/squeezebox/switch.py
index 33926c53e64..f8512124068 100644
--- a/homeassistant/components/squeezebox/switch.py
+++ b/homeassistant/components/squeezebox/switch.py
@@ -22,6 +22,8 @@ from .entity import SqueezeboxEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py
index 62579424d25..db235786817 100644
--- a/homeassistant/components/squeezebox/update.py
+++ b/homeassistant/components/squeezebox/update.py
@@ -118,7 +118,7 @@ class ServerStatusUpdatePlugins(ServerStatusUpdate):
rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY]
return (
(rs or "")
- + "The Plugins will be updated on the next restart triggred by selecting the Install button. Allow enough time for the service to restart. It will become briefly unavailable."
+ + "The Plugins will be updated on the next restart triggered by selecting the Update button. Allow enough time for the service to restart. It will become briefly unavailable."
if self.coordinator.can_server_restart
else rs
)
diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py
index b6e105b9560..366c6adb95b 100644
--- a/homeassistant/components/ssdp/server.py
+++ b/homeassistant/components/ssdp/server.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+from contextlib import ExitStack
import logging
import socket
from time import time
@@ -30,9 +31,6 @@ from homeassistant.helpers.system_info import async_get_system_info
from .common import async_build_source_set
-UPNP_SERVER_MIN_PORT = 40000
-UPNP_SERVER_MAX_PORT = 40100
-
_LOGGER = logging.getLogger(__name__)
@@ -89,24 +87,22 @@ class HassUpnpServiceDevice(UpnpServerDevice):
SERVICES: list[type[UpnpServerService]] = []
-async def _async_find_next_available_port(source: AddressTupleVXType) -> int:
+async def _async_find_next_available_port(
+ source: AddressTupleVXType,
+) -> tuple[int, socket.socket]:
"""Get a free TCP port."""
family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6
test_socket = socket.socket(family, socket.SOCK_STREAM)
- test_socket.setblocking(False)
- test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ test_socket.setblocking(False)
- for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT):
- addr = (source[0], port, *source[2:])
- try:
- test_socket.bind(addr)
- except OSError:
- if port == UPNP_SERVER_MAX_PORT - 1:
- raise
- else:
- return port
-
- raise RuntimeError("unreachable")
+ addr = (source[0], 0, *source[2:])
+ test_socket.bind(addr)
+ port = test_socket.getsockname()[1]
+ except BaseException:
+ test_socket.close()
+ raise
+ return port, test_socket
class Server:
@@ -167,35 +163,43 @@ class Server:
# Start a server on all source IPs.
boot_id = int(time())
- for source_ip in await async_build_source_set(self.hass):
- source_ip_str = str(source_ip)
- if source_ip.version == 6:
- assert source_ip.scope_id is not None
- source_tuple: AddressTupleVXType = (
- source_ip_str,
- 0,
- 0,
- int(source_ip.scope_id),
+ # We use an ExitStack to ensure that all sockets are closed.
+ # The socket is created in _async_find_next_available_port,
+ # and should be kept open until UpnpServer is started to
+ # keep the kernel from reassigning the port.
+ with ExitStack() as stack:
+ for source_ip in await async_build_source_set(self.hass):
+ source_ip_str = str(source_ip)
+ if source_ip.version == 6:
+ assert source_ip.scope_id is not None
+ source_tuple: AddressTupleVXType = (
+ source_ip_str,
+ 0,
+ 0,
+ int(source_ip.scope_id),
+ )
+ else:
+ source_tuple = (source_ip_str, 0)
+ source, target = determine_source_target(source_tuple)
+ source = fix_ipv6_address_scope_id(source) or source
+ http_port, http_socket = await _async_find_next_available_port(source)
+ stack.enter_context(http_socket)
+ _LOGGER.debug(
+ "Binding UPnP HTTP server to: %s:%s", source_ip, http_port
)
- else:
- source_tuple = (source_ip_str, 0)
- source, target = determine_source_target(source_tuple)
- source = fix_ipv6_address_scope_id(source) or source
- http_port = await _async_find_next_available_port(source)
- _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port)
- self._upnp_servers.append(
- UpnpServer(
- source=source,
- target=target,
- http_port=http_port,
- server_device=HassUpnpServiceDevice,
- boot_id=boot_id,
+ self._upnp_servers.append(
+ UpnpServer(
+ source=source,
+ target=target,
+ http_port=http_port,
+ server_device=HassUpnpServiceDevice,
+ boot_id=boot_id,
+ )
)
+ results = await asyncio.gather(
+ *(upnp_server.async_start() for upnp_server in self._upnp_servers),
+ return_exceptions=True,
)
- results = await asyncio.gather(
- *(upnp_server.async_start() for upnp_server in self._upnp_servers),
- return_exceptions=True,
- )
failed_servers = []
for idx, result in enumerate(results):
if isinstance(result, Exception):
diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py
index 02d51cd805e..5a765b5cd6f 100644
--- a/homeassistant/components/starlink/coordinator.py
+++ b/homeassistant/components/starlink/coordinator.py
@@ -66,6 +66,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
config_entry=config_entry,
name=config_entry.title,
update_interval=timedelta(seconds=5),
+ always_update=False,
)
def _get_starlink_data(self) -> StarlinkData:
@@ -76,17 +77,11 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
sleep = get_sleep_config(context)
status, obstruction, alert = status_data(context)
index, _, _, _, _, usage, consumption, *_ = history_stats(
- parse_samples=-1, start=self.history_stats_start, context=context
+ parse_samples=-1 if self.history_stats_start is not None else 1,
+ start=self.history_stats_start,
+ context=context,
)
self.history_stats_start = index["end_counter"]
- if self.data:
- if index["samples"] > 0:
- usage["download_usage"] += self.data.usage["download_usage"]
- usage["upload_usage"] += self.data.usage["upload_usage"]
- consumption["total_energy"] += self.data.consumption["total_energy"]
- else:
- usage = self.data.usage
- consumption = self.data.consumption
return StarlinkData(
location, sleep, status, obstruction, alert, usage, consumption
)
@@ -94,10 +89,9 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
async def _async_update_data(self) -> StarlinkData:
async with asyncio.timeout(4):
try:
- result = await self.hass.async_add_executor_job(self._get_starlink_data)
+ return await self.hass.async_add_executor_job(self._get_starlink_data)
except GrpcError as exc:
raise UpdateFailed from exc
- return result
async def async_stow_starlink(self, stow: bool) -> None:
"""Set whether Starlink system tied to this coordinator should be stowed."""
diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py
index b353051a074..75f5f0a2143 100644
--- a/homeassistant/components/starlink/sensor.py
+++ b/homeassistant/components/starlink/sensor.py
@@ -5,8 +5,10 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
+from typing import TYPE_CHECKING
from homeassistant.components.sensor import (
+ RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -42,6 +44,11 @@ async def async_setup_entry(
for description in SENSORS
)
+ async_add_entities(
+ StarlinkRestoreSensor(config_entry.runtime_data, description)
+ for description in RESTORE_SENSORS
+ )
+
@dataclass(frozen=True, kw_only=True)
class StarlinkSensorEntityDescription(SensorEntityDescription):
@@ -61,6 +68,33 @@ class StarlinkSensorEntity(StarlinkEntity, SensorEntity):
return self.entity_description.value_fn(self.coordinator.data)
+class StarlinkRestoreSensor(StarlinkSensorEntity, RestoreSensor):
+ """A RestoreSensorEntity for Starlink devices. Handles creating unique IDs."""
+
+ _attr_native_value: int | float = 0
+
+ @property
+ def native_value(self) -> int | float:
+ """Calculate the sensor value from current value and the entity description."""
+ new_value = super().native_value
+ if TYPE_CHECKING:
+ assert isinstance(new_value, (int, float))
+ self._attr_native_value += new_value
+ return self._attr_native_value
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ if (
+ last_sensor_data := await self.async_get_last_sensor_data()
+ ) is not None and (
+ last_native_value := last_sensor_data.native_value
+ ) is not None:
+ if TYPE_CHECKING:
+ assert isinstance(last_native_value, (int, float))
+ self._attr_native_value = last_native_value
+
+
SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
StarlinkSensorEntityDescription(
key="ping",
@@ -96,7 +130,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND,
- suggested_display_precision=0,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.status["uplink_throughput_bps"],
),
StarlinkSensorEntityDescription(
@@ -105,7 +140,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND,
- suggested_display_precision=0,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.status["downlink_throughput_bps"],
),
StarlinkSensorEntityDescription(
@@ -125,13 +161,22 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100,
),
StarlinkSensorEntityDescription(
- key="upload",
- translation_key="upload",
- device_class=SensorDeviceClass.DATA_SIZE,
+ key="power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_display_precision=0,
+ value_fn=lambda data: data.consumption["latest_power"],
+ ),
+)
+RESTORE_SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
+ StarlinkSensorEntityDescription(
+ key="energy",
+ device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
- native_unit_of_measurement=UnitOfInformation.BYTES,
- suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
- value_fn=lambda data: data.usage["upload_usage"],
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ value_fn=lambda data: data.consumption["total_energy"],
),
StarlinkSensorEntityDescription(
key="download",
@@ -139,21 +184,18 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
value_fn=lambda data: data.usage["download_usage"],
),
StarlinkSensorEntityDescription(
- key="power",
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfPower.WATT,
- value_fn=lambda data: data.consumption["latest_power"],
- ),
- StarlinkSensorEntityDescription(
- key="energy",
- device_class=SensorDeviceClass.ENERGY,
+ key="upload",
+ translation_key="upload",
+ device_class=SensorDeviceClass.DATA_SIZE,
state_class=SensorStateClass.TOTAL_INCREASING,
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- value_fn=lambda data: data.consumption["total_energy"],
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_display_precision=1,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ value_fn=lambda data: data.usage["upload_usage"],
),
)
diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py
index 34799e366d1..5c80fd1b917 100644
--- a/homeassistant/components/statistics/__init__.py
+++ b/homeassistant/components/statistics/__init__.py
@@ -35,6 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -56,7 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -98,8 +98,3 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Statistics config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py
index d9ff172e0a4..0375ab10777 100644
--- a/homeassistant/components/statistics/config_flow.py
+++ b/homeassistant/components/statistics/config_flow.py
@@ -165,6 +165,7 @@ class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py
index d2824ab10e5..a196364313a 100644
--- a/homeassistant/components/stiebel_eltron/__init__.py
+++ b/homeassistant/components/stiebel_eltron/__init__.py
@@ -98,7 +98,7 @@ async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
hass,
DOMAIN,
"deprecated_yaml",
- breaks_in_ha_version="2025.9.0",
+ breaks_in_ha_version="2025.11.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py
index 415d0a04e7c..f748a6da8bc 100644
--- a/homeassistant/components/sun/condition.py
+++ b/homeassistant/components/sun/condition.py
@@ -3,16 +3,18 @@
from __future__ import annotations
from datetime import datetime, timedelta
-from typing import cast
+from typing import Any, cast
import voluptuous as vol
-from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
+from homeassistant.const import CONF_OPTIONS, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
+ ConditionConfig,
condition_trace_set_result,
condition_trace_update_result,
trace_condition_function,
@@ -21,20 +23,22 @@ from homeassistant.helpers.sun import get_astral_event_date
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.util import dt as dt_util
-_CONDITION_SCHEMA = vol.All(
- vol.Schema(
- {
- **cv.CONDITION_BASE_SCHEMA,
- vol.Required(CONF_CONDITION): "sun",
- vol.Optional("before"): cv.sun_event,
- vol.Optional("before_offset"): cv.time_period,
- vol.Optional("after"): vol.All(
- vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
- ),
- vol.Optional("after_offset"): cv.time_period,
- }
+_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
+ vol.Optional("before"): cv.sun_event,
+ vol.Optional("before_offset"): cv.time_period,
+ vol.Optional("after"): vol.All(
+ vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
),
- cv.has_at_least_one_key("before", "after"),
+ vol.Optional("after_offset"): cv.time_period,
+}
+
+_CONDITION_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OPTIONS): vol.All(
+ _OPTIONS_SCHEMA_DICT,
+ cv.has_at_least_one_key("before", "after"),
+ )
+ }
)
@@ -125,24 +129,36 @@ def sun(
class SunCondition(Condition):
"""Sun condition."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize condition."""
- self._config = config
- self._hass = hass
+ _options: dict[str, Any]
+
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config."""
+ complete_config = move_top_level_schema_fields_to_options(
+ complete_config, _OPTIONS_SCHEMA_DICT
+ )
+ return await super().async_validate_complete_config(hass, complete_config)
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
- return _CONDITION_SCHEMA(config) # type: ignore[no-any-return]
+ return cast(ConfigType, _CONDITION_SCHEMA(config))
+
+ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
+ """Initialize condition."""
+ assert config.options is not None
+ self._options = config.options
async def async_get_checker(self) -> ConditionCheckerType:
"""Wrap action method with sun based condition."""
- before = self._config.get("before")
- after = self._config.get("after")
- before_offset = self._config.get("before_offset")
- after_offset = self._config.get("after_offset")
+ before = self._options.get("before")
+ after = self._options.get("after")
+ before_offset = self._options.get("before_offset")
+ after_offset = self._options.get("after_offset")
@trace_condition_function
def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json
index b73cf8f849d..be5aa09cf34 100644
--- a/homeassistant/components/switch/strings.json
+++ b/homeassistant/components/switch/strings.json
@@ -14,6 +14,9 @@
"changed_states": "[%key:common::device_automation::trigger_type::changed_states%]",
"turned_on": "[%key:common::device_automation::trigger_type::turned_on%]",
"turned_off": "[%key:common::device_automation::trigger_type::turned_off%]"
+ },
+ "extra_fields": {
+ "for": "[%key:common::device_automation::extra_fields::for%]"
}
},
"entity_component": {
diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py
index b511e2af2b2..dfb5ded2791 100644
--- a/homeassistant/components/switch_as_x/__init__.py
+++ b/homeassistant/components/switch_as_x/__init__.py
@@ -52,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
source_entity_removed=source_entity_removed,
)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options[CONF_TARGET_DOMAIN],)
@@ -113,11 +113,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py
index cf442256cbe..4b44af63234 100644
--- a/homeassistant/components/switch_as_x/config_flow.py
+++ b/homeassistant/components/switch_as_x/config_flow.py
@@ -56,6 +56,7 @@ class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
VERSION = 1
MINOR_VERSION = 3
diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py
index acf37fe916b..415ba4d48da 100644
--- a/homeassistant/components/switchbot/__init__.py
+++ b/homeassistant/components/switchbot/__init__.py
@@ -74,11 +74,12 @@ PLATFORMS_BY_TYPE = {
],
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
- SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
+ SupportedModels.K11_PLUS_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
+ SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.HUB3.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
SupportedModels.LOCK_LITE.value: [
Platform.BINARY_SENSOR,
@@ -95,6 +96,11 @@ PLATFORMS_BY_TYPE = {
SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR],
SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR],
+ SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
+ SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR],
+ SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR],
+ SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
+ SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -111,11 +117,12 @@ CLASS_BY_DEVICE = {
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
- SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
+ SupportedModels.K11_PLUS_VACUUM.value: switchbot.SwitchbotVacuum,
+ SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock,
SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier,
@@ -123,6 +130,11 @@ CLASS_BY_DEVICE = {
SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3,
SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3,
+ SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight,
+ SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight,
+ SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
+ SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
+ SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
}
diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py
index b207440d796..5c856bc216c 100644
--- a/homeassistant/components/switchbot/config_flow.py
+++ b/homeassistant/components/switchbot/config_flow.py
@@ -11,12 +11,15 @@ from switchbot import (
SwitchbotApiError,
SwitchbotAuthenticationError,
SwitchbotModel,
+ fetch_cloud_devices,
parse_advertisement_data,
)
import voluptuous as vol
from homeassistant.components.bluetooth import (
+ BluetoothScanningMode,
BluetoothServiceInfoBleak,
+ async_current_scanners,
async_discovered_service_info,
)
from homeassistant.config_entries import (
@@ -87,6 +90,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovered_adv: SwitchBotAdvertisement | None = None
self._discovered_advs: dict[str, SwitchBotAdvertisement] = {}
+ self._cloud_username: str | None = None
+ self._cloud_password: str | None = None
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@@ -176,9 +181,17 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SwitchBot API auth step."""
- errors = {}
+ errors: dict[str, str] = {}
assert self._discovered_adv is not None
- description_placeholders = {}
+ description_placeholders: dict[str, str] = {}
+
+ # If we have saved credentials from cloud login, try them first
+ if user_input is None and self._cloud_username and self._cloud_password:
+ user_input = {
+ CONF_USERNAME: self._cloud_username,
+ CONF_PASSWORD: self._cloud_password,
+ }
+
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model]
@@ -200,6 +213,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
+ # Clear saved credentials if auth failed
+ self._cloud_username = None
+ self._cloud_password = None
else:
return await self.async_step_encrypted_key(key_details)
@@ -239,7 +255,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the encryption key step."""
- errors = {}
+ errors: dict[str, str] = {}
assert self._discovered_adv is not None
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
@@ -308,7 +324,73 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the user step to pick discovered device."""
+ """Handle the user step to choose cloud login or direct discovery."""
+ # Check if all scanners are in active mode
+ # If so, skip the menu and go directly to device selection
+ scanners = async_current_scanners(self.hass)
+ if scanners and all(
+ scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners
+ ):
+ # All scanners are active, skip the menu
+ return await self.async_step_select_device()
+
+ return self.async_show_menu(
+ step_id="user",
+ menu_options=["cloud_login", "select_device"],
+ )
+
+ async def async_step_cloud_login(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the cloud login step."""
+ errors: dict[str, str] = {}
+ description_placeholders: dict[str, str] = {}
+
+ if user_input is not None:
+ try:
+ await fetch_cloud_devices(
+ async_get_clientsession(self.hass),
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ )
+ except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex:
+ _LOGGER.debug(
+ "Failed to connect to SwitchBot API: %s", ex, exc_info=True
+ )
+ raise AbortFlow(
+ "api_error", description_placeholders={"error_detail": str(ex)}
+ ) from ex
+ except SwitchbotAuthenticationError as ex:
+ _LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
+ errors = {"base": "auth_failed"}
+ description_placeholders = {"error_detail": str(ex)}
+ else:
+ # Save credentials temporarily for the duration of this flow
+ # to avoid re-prompting if encrypted device auth is needed
+ # These will be discarded when the flow completes
+ self._cloud_username = user_input[CONF_USERNAME]
+ self._cloud_password = user_input[CONF_PASSWORD]
+ return await self.async_step_select_device()
+
+ user_input = user_input or {}
+ return self.async_show_form(
+ step_id="cloud_login",
+ errors=errors,
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_USERNAME, default=user_input.get(CONF_USERNAME)
+ ): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+ ),
+ description_placeholders=description_placeholders,
+ )
+
+ async def async_step_select_device(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the step to pick discovered device."""
errors: dict[str, str] = {}
device_adv: SwitchBotAdvertisement | None = None
if user_input is not None:
@@ -333,7 +415,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
return self.async_show_form(
- step_id="user",
+ step_id="select_device",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py
index c57b8d467cc..80f7978f4dc 100644
--- a/homeassistant/components/switchbot/const.py
+++ b/homeassistant/components/switchbot/const.py
@@ -51,6 +51,12 @@ class SupportedModels(StrEnum):
EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier"
FLOOR_LAMP = "floor_lamp"
STRIP_LIGHT_3 = "strip_light_3"
+ RGBICWW_STRIP_LIGHT = "rgbicww_strip_light"
+ RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp"
+ PLUG_MINI_EU = "plug_mini_eu"
+ RELAY_SWITCH_2PM = "relay_switch_2pm"
+ K11_PLUS_VACUUM = "k11+_vacuum"
+ GARAGE_DOOR_OPENER = "garage_door_opener"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -81,6 +87,12 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER,
SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP,
SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3,
+ SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT,
+ SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP,
+ SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU,
+ SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM,
+ SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
+ SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -112,6 +124,11 @@ ENCRYPTED_MODELS = {
SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
SwitchbotModel.FLOOR_LAMP,
SwitchbotModel.STRIP_LIGHT_3,
+ SwitchbotModel.RGBICWW_STRIP_LIGHT,
+ SwitchbotModel.RGBICWW_FLOOR_LAMP,
+ SwitchbotModel.PLUG_MINI_EU,
+ SwitchbotModel.RELAY_SWITCH_2PM,
+ SwitchbotModel.GARAGE_DOOR_OPENER,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -128,6 +145,11 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3,
SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3,
+ SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight,
+ SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight,
+ SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
+ SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
+ SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py
index 9124dc7f846..09cb13c3aea 100644
--- a/homeassistant/components/switchbot/cover.py
+++ b/homeassistant/components/switchbot/cover.py
@@ -35,7 +35,9 @@ async def async_setup_entry(
) -> None:
"""Set up Switchbot curtain based on a config entry."""
coordinator = entry.runtime_data
- if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt):
+ if isinstance(coordinator.device, switchbot.SwitchbotGarageDoorOpener):
+ async_add_entities([SwitchbotGarageDoorOpenerEntity(coordinator)])
+ elif isinstance(coordinator.device, switchbot.SwitchbotBlindTilt):
async_add_entities([SwitchBotBlindTiltEntity(coordinator)])
elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade):
async_add_entities([SwitchBotRollerShadeEntity(coordinator)])
@@ -295,3 +297,30 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_closed = self.parsed_data["position"] <= 20
self.async_write_ha_state()
+
+
+class SwitchbotGarageDoorOpenerEntity(SwitchbotEntity, CoverEntity):
+ """Representation of a Switchbot garage door."""
+
+ _device: switchbot.SwitchbotGarageDoorOpener
+ _attr_device_class = CoverDeviceClass.GARAGE
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ _attr_translation_key = "garage_door"
+ _attr_name = None
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Return true if cover is closed, else False."""
+ return not self._device.door_open()
+
+ @exception_handler
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the garage door."""
+ await self._device.open()
+ self.async_write_ha_state()
+
+ @exception_handler
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close the garage door."""
+ await self._device.close()
+ self.async_write_ha_state()
diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py
index b7ee36fc1ae..a64950c0f7d 100644
--- a/homeassistant/components/switchbot/entity.py
+++ b/homeassistant/components/switchbot/entity.py
@@ -6,6 +6,7 @@ from collections.abc import Callable, Coroutine, Mapping
import logging
from typing import Any, Concatenate
+import switchbot
from switchbot import Switchbot, SwitchbotDevice
from switchbot.devices.device import SwitchbotOperationError
@@ -46,6 +47,7 @@ class SwitchbotEntity(
model=coordinator.model, # Sometimes the modelName is missing from the advertisement data
name=coordinator.device_name,
)
+ self._channel: int | None = None
if ":" not in self._address:
# MacOS Bluetooth addresses are not mac addresses
return
@@ -60,6 +62,8 @@ class SwitchbotEntity(
@property
def parsed_data(self) -> dict[str, Any]:
"""Return parsed device data for this entity."""
+ if isinstance(self.coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
+ return self.coordinator.device.get_parsed_data(self._channel)
return self.coordinator.device.parsed_data
@property
diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json
index cf9217bf70b..b04c04188d1 100644
--- a/homeassistant/components/switchbot/icons.json
+++ b/homeassistant/components/switchbot/icons.json
@@ -99,7 +99,30 @@
"rose": "mdi:flower",
"colorful": "mdi:looks",
"flickering": "mdi:led-strip-variant",
- "breathing": "mdi:heart-pulse"
+ "breathing": "mdi:heart-pulse",
+ "romance": "mdi:heart-outline",
+ "energy": "mdi:run",
+ "heartbeat": "mdi:heart-pulse",
+ "party": "mdi:party-popper",
+ "dynamic": "mdi:palette",
+ "mystery": "mdi:alien-outline",
+ "lightning": "mdi:flash-outline",
+ "rock": "mdi:guitar-electric",
+ "starlight": "mdi:creation",
+ "valentine_day": "mdi:emoticon-kiss-outline",
+ "dream": "mdi:sleep",
+ "alarm": "mdi:alarm-light",
+ "fireworks": "mdi:firework",
+ "waves": "mdi:waves",
+ "rainbow": "mdi:looks",
+ "game": "mdi:gamepad-variant-outline",
+ "meditation": "mdi:meditation",
+ "starlit_sky": "mdi:weather-night",
+ "sleep": "mdi:power-sleep",
+ "movie": "mdi:popcorn",
+ "sunrise": "mdi:weather-sunset-up",
+ "new_year": "mdi:glass-wine",
+ "cherry_blossom": "mdi:flower-outline"
}
}
}
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 175aacf5d4c..2d741c1301b 100644
--- a/homeassistant/components/switchbot/manifest.json
+++ b/homeassistant/components/switchbot/manifest.json
@@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
- "requirements": ["PySwitchbot==0.69.0"]
+ "requirements": ["PySwitchbot==0.71.0"]
}
diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py
index 9196453e98c..ab400b58065 100644
--- a/homeassistant/components/switchbot/sensor.py
+++ b/homeassistant/components/switchbot/sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import switchbot
from switchbot import HumidifierWaterLevel
from switchbot.const.air_purifier import AirQualityLevel
@@ -25,8 +26,10 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import DOMAIN
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
@@ -133,13 +136,22 @@ async def async_setup_entry(
) -> None:
"""Set up Switchbot sensor based on a config entry."""
coordinator = entry.runtime_data
- entities = [
- SwitchBotSensor(coordinator, sensor)
- for sensor in coordinator.device.parsed_data
- if sensor in SENSOR_TYPES
- ]
- entities.append(SwitchbotRSSISensor(coordinator, "rssi"))
- async_add_entities(entities)
+ sensor_entities: list[SensorEntity] = []
+ if isinstance(coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
+ sensor_entities.extend(
+ SwitchBotSensor(coordinator, sensor, channel)
+ for channel in range(1, coordinator.device.channel + 1)
+ for sensor in coordinator.device.get_parsed_data(channel)
+ if sensor in SENSOR_TYPES
+ )
+ else:
+ sensor_entities.extend(
+ SwitchBotSensor(coordinator, sensor)
+ for sensor in coordinator.device.parsed_data
+ if sensor in SENSOR_TYPES
+ )
+ sensor_entities.append(SwitchbotRSSISensor(coordinator, "rssi"))
+ async_add_entities(sensor_entities)
class SwitchBotSensor(SwitchbotEntity, SensorEntity):
@@ -149,13 +161,27 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity):
self,
coordinator: SwitchbotDataUpdateCoordinator,
sensor: str,
+ channel: int | None = None,
) -> None:
"""Initialize the Switchbot sensor."""
super().__init__(coordinator)
self._sensor = sensor
- self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}"
+ self._channel = channel
self.entity_description = SENSOR_TYPES[sensor]
+ if channel:
+ self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}-{channel}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (DOMAIN, f"{coordinator.base_unique_id}-channel-{channel}")
+ },
+ manufacturer="SwitchBot",
+ model_id="RelaySwitch2PM",
+ name=f"{coordinator.device_name} Channel {channel}",
+ )
+ else:
+ self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}"
+
@property
def native_value(self) -> str | int | None:
"""Return the state of the sensor."""
diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json
index 35482016e90..b2e2d2dc4b1 100644
--- a/homeassistant/components/switchbot/strings.json
+++ b/homeassistant/components/switchbot/strings.json
@@ -3,6 +3,24 @@
"flow_title": "{name} ({address})",
"step": {
"user": {
+ "description": "One or more of your Bluetooth adapters is using passive scanning, which may not discover all SwitchBot devices. Would you like to sign in to your SwitchBot account to download device information and automate discovery? If you're not sure, we recommend signing in.",
+ "menu_options": {
+ "cloud_login": "Sign in to SwitchBot account",
+ "select_device": "Continue without signing in"
+ }
+ },
+ "cloud_login": {
+ "description": "Please provide your SwitchBot app username and password. This data won't be saved and is only used to retrieve device model information to automate discovery. Usernames and passwords are case-sensitive.",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::switchbot::config::step::encrypted_auth::data_description::username%]",
+ "password": "[%key:component::switchbot::config::step::encrypted_auth::data_description::password%]"
+ }
+ },
+ "select_device": {
"data": {
"address": "MAC address"
},
@@ -270,7 +288,30 @@
"rose": "Rose",
"colorful": "Colorful",
"flickering": "Flickering",
- "breathing": "Breathing"
+ "breathing": "Breathing",
+ "romance": "Romance",
+ "energy": "Energy",
+ "heartbeat": "Heartbeat",
+ "party": "Party",
+ "dynamic": "Dynamic",
+ "mystery": "Mystery",
+ "lightning": "Lightning",
+ "rock": "Rock",
+ "starlight": "Starlight",
+ "valentine_day": "Valentine's Day",
+ "dream": "Dream",
+ "alarm": "Alarm",
+ "fireworks": "Fireworks",
+ "waves": "Waves",
+ "rainbow": "Rainbow",
+ "game": "Game",
+ "meditation": "Meditation",
+ "starlit_sky": "Starlit Sky",
+ "sleep": "Sleep",
+ "movie": "Movie",
+ "sunrise": "Sunrise",
+ "new_year": "New Year",
+ "cherry_blossom": "Cherry Blossom"
}
}
}
diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py
index fd1e8bb6393..d67aaed3412 100644
--- a/homeassistant/components/switchbot/switch.py
+++ b/homeassistant/components/switchbot/switch.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any
import switchbot
@@ -9,13 +10,16 @@ import switchbot
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
+from .const import DOMAIN
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
-from .entity import SwitchbotSwitchedEntity
+from .entity import SwitchbotSwitchedEntity, exception_handler
PARALLEL_UPDATES = 0
+_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@@ -24,7 +28,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot based on a config entry."""
- async_add_entities([SwitchBotSwitch(entry.runtime_data)])
+ coordinator = entry.runtime_data
+
+ if isinstance(coordinator.device, switchbot.SwitchbotRelaySwitch2PM):
+ entries = [
+ SwitchbotMultiChannelSwitch(coordinator, channel)
+ for channel in range(1, coordinator.device.channel + 1)
+ ]
+ async_add_entities(entries)
+ else:
+ async_add_entities([SwitchBotSwitch(coordinator)])
class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity):
@@ -67,3 +80,49 @@ class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity):
**super().extra_state_attributes,
"switch_mode": self._device.switch_mode(),
}
+
+
+class SwitchbotMultiChannelSwitch(SwitchbotSwitchedEntity, SwitchEntity):
+ """Representation of a Switchbot multi-channel switch."""
+
+ _attr_device_class = SwitchDeviceClass.SWITCH
+ _device: switchbot.Switchbot
+ _attr_name = None
+
+ def __init__(
+ self, coordinator: SwitchbotDataUpdateCoordinator, channel: int
+ ) -> None:
+ """Initialize the Switchbot."""
+ super().__init__(coordinator)
+ self._channel = channel
+ self._attr_unique_id = f"{coordinator.base_unique_id}-{channel}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{coordinator.base_unique_id}-channel-{channel}")},
+ manufacturer="SwitchBot",
+ model_id="RelaySwitch2PM",
+ name=f"{coordinator.device_name} Channel {channel}",
+ )
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if device is on."""
+ return self._device.is_on(self._channel)
+
+ @exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn device on."""
+ _LOGGER.debug(
+ "Turn Switchbot device on %s, channel %d", self._address, self._channel
+ )
+ await self._device.turn_on(self._channel)
+ self.async_write_ha_state()
+
+ @exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn device off."""
+ _LOGGER.debug(
+ "Turn Switchbot device off %s, channel %d", self._address, self._channel
+ )
+ await self._device.turn_off(self._channel)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py
index edf30984fe6..d0fb79ebdde 100644
--- a/homeassistant/components/switchbot_cloud/__init__.py
+++ b/homeassistant/components/switchbot_cloud/__init__.py
@@ -31,6 +31,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
+ Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
@@ -57,6 +58,7 @@ class SwitchbotDevices:
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
+ humidifiers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@dataclass
@@ -141,6 +143,7 @@ async def make_device_data(
"Relay Switch 1PM",
"Plug Mini (US)",
"Plug Mini (JP)",
+ "Plug Mini (EU)",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
@@ -184,11 +187,41 @@ async def make_device_data(
devices_data.buttons.append((device, coordinator))
else:
devices_data.switches.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type in [
+ "Relay Switch 2PM",
+ ]:
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id
+ )
+ devices_data.sensors.append((device, coordinator))
+ devices_data.switches.append((device, coordinator))
+
if isinstance(device, Device) and device.device_type.startswith("Air Purifier"):
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type in [
+ "Motion Sensor",
+ "Contact Sensor",
+ ]:
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id, True
+ )
+ devices_data.sensors.append((device, coordinator))
+ devices_data.binary_sensors.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type in ["Hub 3"]:
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id, True
+ )
+ devices_data.sensors.append((device, coordinator))
+ devices_data.binary_sensors.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type in ["Water Detector"]:
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id, True
+ )
+ devices_data.binary_sensors.append((device, coordinator))
+ devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan",
@@ -226,12 +259,33 @@ async def make_device_data(
"Strip Light 3",
"Floor Lamp",
"Color Bulb",
+ "RGBICWW Floor Lamp",
+ "RGBICWW Strip Light",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.lights.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type == "Humidifier2":
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id
+ )
+ devices_data.humidifiers.append((device, coordinator))
+
+ if isinstance(device, Device) and device.device_type == "Humidifier":
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id
+ )
+ devices_data.humidifiers.append((device, coordinator))
+ devices_data.sensors.append((device, coordinator))
+ if isinstance(device, Device) and device.device_type == "Climate Panel":
+ coordinator = await coordinator_for_device(
+ hass, entry, api, device, coordinators_by_id
+ )
+ devices_data.binary_sensors.append((device, coordinator))
+ devices_data.sensors.append((device, coordinator))
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry."""
@@ -377,7 +431,7 @@ def _create_handle_webhook(
):
_LOGGER.debug("Received invalid data from switchbot webhook %s", repr(data))
return
-
+ _LOGGER.debug("Received data from switchbot webhook: %s", repr(data))
deviceMac = data["context"]["deviceMac"]
if deviceMac not in coordinators_by_id:
diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py
index a1ad6d6887d..a9148076ae7 100644
--- a/homeassistant/components/switchbot_cloud/binary_sensor.py
+++ b/homeassistant/components/switchbot_cloud/binary_sensor.py
@@ -1,6 +1,8 @@
"""Support for SwitchBot Cloud binary sensors."""
+from collections.abc import Callable
from dataclasses import dataclass
+from typing import Any
from switchbot_api import Device, SwitchBotAPI
@@ -26,6 +28,7 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription)
# Value or values to consider binary sensor to be "on"
on_value: bool | str = True
+ value_fn: Callable[[dict[str, Any]], bool | None] | None = None
CALIBRATION_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
@@ -43,6 +46,34 @@ DOOR_OPEN_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
on_value="opened",
)
+MOVE_DETECTED_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
+ key="moveDetected",
+ device_class=BinarySensorDeviceClass.MOTION,
+ value_fn=(
+ lambda data: data.get("moveDetected") is True
+ or data.get("detectionState") == "DETECTED"
+ ),
+)
+
+IS_LIGHT_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
+ key="brightness",
+ device_class=BinarySensorDeviceClass.LIGHT,
+ on_value="bright",
+)
+
+LEAK_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
+ key="status",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ value_fn=lambda data: any(data.get(key) for key in ("status", "detectionState")),
+)
+
+OPEN_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription(
+ key="openState",
+ device_class=BinarySensorDeviceClass.OPENING,
+ value_fn=lambda data: data.get("openState") in ("open", "timeOutNotClose"),
+)
+
+
BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Smart Lock": (
CALIBRATION_DESCRIPTION,
@@ -65,6 +96,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Roller Shade": (CALIBRATION_DESCRIPTION,),
"Blind Tilt": (CALIBRATION_DESCRIPTION,),
"Garage Door Opener": (DOOR_OPEN_DESCRIPTION,),
+ "Motion Sensor": (MOVE_DETECTED_DESCRIPTION,),
+ "Contact Sensor": (
+ MOVE_DETECTED_DESCRIPTION,
+ IS_LIGHT_DESCRIPTION,
+ OPEN_DESCRIPTION,
+ ),
+ "Hub 3": (MOVE_DETECTED_DESCRIPTION,),
+ "Water Detector": (LEAK_DESCRIPTION,),
+ "Climate Panel": (
+ IS_LIGHT_DESCRIPTION,
+ MOVE_DETECTED_DESCRIPTION,
+ ),
}
@@ -108,6 +151,9 @@ class SwitchBotCloudBinarySensor(SwitchBotCloudEntity, BinarySensorEntity):
if not self.coordinator.data:
return None
+ if self.entity_description.value_fn:
+ return self.entity_description.value_fn(self.coordinator.data)
+
return (
self.coordinator.data.get(self.entity_description.key)
== self.entity_description.on_value
diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py
index 27698420ae9..db100454d9c 100644
--- a/homeassistant/components/switchbot_cloud/climate.py
+++ b/homeassistant/components/switchbot_cloud/climate.py
@@ -1,24 +1,30 @@
"""Support for SwitchBot Air Conditioner remotes."""
+from logging import getLogger
from typing import Any
from switchbot_api import AirConditionerCommands
from homeassistant.components import climate as FanState
from homeassistant.components.climate import (
+ ATTR_FAN_MODE,
+ ATTR_TEMPERATURE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import SwitchbotCloudData
from .const import DOMAIN
from .entity import SwitchBotCloudEntity
+_LOGGER = getLogger(__name__)
+
_SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = {
HVACMode.HEAT_COOL: 1,
HVACMode.COOL: 2,
@@ -52,7 +58,7 @@ async def async_setup_entry(
)
-class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
+class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity, RestoreEntity):
"""Representation of a SwitchBot air conditioner.
As it is an IR device, we don't know the actual state.
@@ -75,6 +81,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
HVACMode.DRY,
HVACMode.FAN_ONLY,
HVACMode.HEAT,
+ HVACMode.OFF,
]
_attr_hvac_mode = HVACMode.FAN_ONLY
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@@ -83,6 +90,39 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
_attr_precision = 1
_attr_name = None
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+
+ if not (
+ last_state := await self.async_get_last_state()
+ ) or last_state.state in (
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ ):
+ return
+ _LOGGER.debug("Last state attributes: %s", last_state.attributes)
+ self._attr_hvac_mode = HVACMode(last_state.state)
+ self._attr_fan_mode = last_state.attributes.get(
+ ATTR_FAN_MODE, self._attr_fan_mode
+ )
+ self._attr_target_temperature = last_state.attributes.get(
+ ATTR_TEMPERATURE, self._attr_target_temperature
+ )
+
+ def _get_mode(self, hvac_mode: HVACMode | None) -> int:
+ new_hvac_mode = hvac_mode or self._attr_hvac_mode
+ _LOGGER.debug(
+ "Received hvac_mode: %s (Currently set as %s)",
+ hvac_mode,
+ self._attr_hvac_mode,
+ )
+ if new_hvac_mode == HVACMode.OFF:
+ return _SWITCHBOT_HVAC_MODES.get(
+ self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE
+ )
+ return _SWITCHBOT_HVAC_MODES.get(new_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE)
+
async def _do_send_command(
self,
hvac_mode: HVACMode | None = None,
@@ -90,15 +130,16 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity):
temperature: float | None = None,
) -> None:
new_temperature = temperature or self._attr_target_temperature
- new_mode = _SWITCHBOT_HVAC_MODES.get(
- hvac_mode or self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE
- )
+ new_mode = self._get_mode(hvac_mode)
new_fan_speed = _SWITCHBOT_FAN_MODES.get(
fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE
)
+ new_power_state = "on" if hvac_mode != HVACMode.OFF else "off"
+ command = f"{int(new_temperature)},{new_mode},{new_fan_speed},{new_power_state}"
+ _LOGGER.debug("Sending command to %s: %s", self._attr_unique_id, command)
await self.send_api_command(
AirConditionerCommands.SET_ALL,
- parameters=f"{int(new_temperature)},{new_mode},{new_fan_speed},on",
+ parameters=command,
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py
index 23a212075c4..4f70e5f594b 100644
--- a/homeassistant/components/switchbot_cloud/const.py
+++ b/homeassistant/components/switchbot_cloud/const.py
@@ -20,6 +20,12 @@ VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
+HUMIDITY_LEVELS = {
+ 34: 101, # Low humidity mode
+ 67: 102, # Medium humidity mode
+ 100: 103, # High humidity mode
+}
+
class AirPurifierMode(Enum):
"""Air Purifier Modes."""
@@ -33,3 +39,21 @@ class AirPurifierMode(Enum):
def get_modes(cls) -> list[str]:
"""Return a list of available air purifier modes as lowercase strings."""
return [mode.name.lower() for mode in cls]
+
+
+class Humidifier2Mode(Enum):
+ """Enumerates the available modes for a SwitchBot humidifier2."""
+
+ HIGH = 1
+ MEDIUM = 2
+ LOW = 3
+ QUIET = 4
+ TARGET_HUMIDITY = 5
+ SLEEP = 6
+ AUTO = 7
+ DRYING_FILTER = 8
+
+ @classmethod
+ def get_modes(cls) -> list[str]:
+ """Return a list of available humidifier2 modes as lowercase strings."""
+ return [mode.name.lower() for mode in cls]
diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py
index 77f0b960d25..e5e7b745cbb 100644
--- a/homeassistant/components/switchbot_cloud/cover.py
+++ b/homeassistant/components/switchbot_cloud/cover.py
@@ -109,15 +109,13 @@ class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0))
+ await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=0)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self.send_api_command(
- RollerShadeCommands.SET_POSITION, parameters=str(100)
- )
+ await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=100)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
@@ -126,7 +124,7 @@ class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover):
position: int | None = kwargs.get("position")
if position is not None:
await self.send_api_command(
- RollerShadeCommands.SET_POSITION, parameters=str(100 - position)
+ RollerShadeCommands.SET_POSITION, parameters=(100 - position)
)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py
index 5eb96ed3ac8..376ed47f79f 100644
--- a/homeassistant/components/switchbot_cloud/entity.py
+++ b/homeassistant/components/switchbot_cloud/entity.py
@@ -44,7 +44,7 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]):
self,
command: Commands,
command_type: str = "command",
- parameters: dict | str = "default",
+ parameters: dict | str | int = "default",
) -> None:
"""Send command to device."""
await self._api.send_command(
diff --git a/homeassistant/components/switchbot_cloud/humidifier.py b/homeassistant/components/switchbot_cloud/humidifier.py
new file mode 100644
index 00000000000..dc4824bd890
--- /dev/null
+++ b/homeassistant/components/switchbot_cloud/humidifier.py
@@ -0,0 +1,155 @@
+"""Support for Switchbot humidifier."""
+
+import asyncio
+from typing import Any
+
+from switchbot_api import CommonCommands, HumidifierCommands, HumidifierV2Commands
+
+from homeassistant.components.humidifier import (
+ MODE_AUTO,
+ MODE_NORMAL,
+ HumidifierDeviceClass,
+ HumidifierEntity,
+ HumidifierEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import SwitchbotCloudData
+from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode
+from .entity import SwitchBotCloudEntity
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Switchbot based on a config entry."""
+ data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities(
+ SwitchBotHumidifier(data.api, device, coordinator)
+ if device.device_type == "Humidifier"
+ else SwitchBotEvaporativeHumidifier(data.api, device, coordinator)
+ for device, coordinator in data.devices.humidifiers
+ )
+
+
+class SwitchBotHumidifier(SwitchBotCloudEntity, HumidifierEntity):
+ """Representation of a Switchbot humidifier."""
+
+ _attr_supported_features = HumidifierEntityFeature.MODES
+ _attr_device_class = HumidifierDeviceClass.HUMIDIFIER
+ _attr_available_modes = [MODE_NORMAL, MODE_AUTO]
+ _attr_min_humidity = 1
+ _attr_translation_key = "humidifier"
+ _attr_name = None
+ _attr_target_humidity = 50
+
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
+ if coord_data := self.coordinator.data:
+ self._attr_is_on = coord_data.get("power") == STATE_ON
+ self._attr_mode = MODE_AUTO if coord_data.get("auto") else MODE_NORMAL
+ self._attr_current_humidity = coord_data.get("humidity")
+
+ async def async_set_humidity(self, humidity: int) -> None:
+ """Set new target humidity."""
+ self.target_humidity, parameters = self._map_humidity_to_supported_level(
+ humidity
+ )
+ await self.send_api_command(
+ HumidifierCommands.SET_MODE, parameters=str(parameters)
+ )
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_set_mode(self, mode: str) -> None:
+ """Set new target humidity."""
+ if mode == MODE_AUTO:
+ await self.send_api_command(HumidifierCommands.SET_MODE, parameters=mode)
+ else:
+ await self.send_api_command(
+ HumidifierCommands.SET_MODE, parameters=str(102)
+ )
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self.send_api_command(CommonCommands.ON)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self.send_api_command(CommonCommands.OFF)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ def _map_humidity_to_supported_level(self, humidity: int) -> tuple[int, int]:
+ """Map any humidity to the closest supported level and its parameter."""
+ if humidity <= 34:
+ return 34, HUMIDITY_LEVELS[34]
+ if humidity <= 67:
+ return 67, HUMIDITY_LEVELS[67]
+ return 100, HUMIDITY_LEVELS[100]
+
+
+class SwitchBotEvaporativeHumidifier(SwitchBotCloudEntity, HumidifierEntity):
+ """Representation of a Switchbot humidifier v2."""
+
+ _attr_supported_features = HumidifierEntityFeature.MODES
+ _attr_device_class = HumidifierDeviceClass.HUMIDIFIER
+ _attr_available_modes = Humidifier2Mode.get_modes()
+ _attr_translation_key = "evaporative_humidifier"
+ _attr_name = None
+ _attr_target_humidity = 50
+
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
+ if coord_data := self.coordinator.data:
+ self._attr_is_on = coord_data.get("power") == STATE_ON
+ self._attr_mode = (
+ Humidifier2Mode(coord_data.get("mode")).name.lower()
+ if coord_data.get("mode") is not None
+ else None
+ )
+ self._attr_current_humidity = (
+ coord_data.get("humidity")
+ if coord_data.get("humidity") != 127
+ else None
+ )
+
+ async def async_set_humidity(self, humidity: int) -> None:
+ """Set new target humidity."""
+ assert self.coordinator.data is not None
+ self._attr_target_humidity = humidity
+ params = {"mode": self.coordinator.data["mode"], "humidity": humidity}
+ await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_set_mode(self, mode: str) -> None:
+ """Set new target mode."""
+ assert self.coordinator.data is not None
+ params = {"mode": Humidifier2Mode[mode.upper()].value}
+ await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self.send_api_command(CommonCommands.ON)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self.send_api_command(CommonCommands.OFF)
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json
index 2a13cbe7579..c7624d3f83d 100644
--- a/homeassistant/components/switchbot_cloud/icons.json
+++ b/homeassistant/components/switchbot_cloud/icons.json
@@ -17,6 +17,39 @@
}
}
}
+ },
+ "sensor": {
+ "light_level": {
+ "default": "mdi:brightness-7",
+ "state": {
+ "1": "mdi:brightness-1",
+ "2": "mdi:brightness-1",
+ "3": "mdi:brightness-1",
+ "4": "mdi:brightness-1",
+ "5": "mdi:brightness-2",
+ "6": "mdi:brightness-3",
+ "7": "mdi:brightness-4",
+ "8": "mdi:brightness-5",
+ "9": "mdi:brightness-6",
+ "10": "mdi:brightness-7"
+ }
+ }
+ },
+ "humidifier": {
+ "evaporative_humidifier": {
+ "state_attributes": {
+ "mode": {
+ "state": {
+ "high": "mdi:water-plus",
+ "medium": "mdi:water",
+ "low": "mdi:water-outline",
+ "quiet": "mdi:volume-off",
+ "target_humidity": "mdi:target",
+ "drying_filter": "mdi:water-remove"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py
index 645c6b4c62b..77062702831 100644
--- a/homeassistant/components/switchbot_cloud/light.py
+++ b/homeassistant/components/switchbot_cloud/light.py
@@ -27,6 +27,11 @@ def value_map_brightness(value: int) -> int:
return int(value / 255 * 100)
+def brightness_map_value(value: int) -> int:
+ """Return brightness from map value."""
+ return int(value * 255 / 100)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
@@ -52,13 +57,14 @@ class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity):
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
-
power: str | None = self.coordinator.data.get("power")
brightness: int | None = self.coordinator.data.get("brightness")
color: str | None = self.coordinator.data.get("color")
color_temperature: int | None = self.coordinator.data.get("colorTemperature")
self._attr_is_on = power == "on" if power else None
- self._attr_brightness: int | None = brightness if brightness else None
+ self._attr_brightness: int | None = (
+ brightness_map_value(brightness) if brightness else None
+ )
self._attr_rgb_color: tuple | None = (
(tuple(int(i) for i in color.split(":"))) if color else None
)
diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py
index 74a9e9d8b1e..ed852cc7420 100644
--- a/homeassistant/components/switchbot_cloud/lock.py
+++ b/homeassistant/components/switchbot_cloud/lock.py
@@ -2,14 +2,14 @@
from typing import Any
-from switchbot_api import LockCommands
+from switchbot_api import Device, LockCommands, LockV2Commands, Remote, SwitchBotAPI
-from homeassistant.components.lock import LockEntity
+from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import SwitchbotCloudData
+from . import SwitchbotCloudData, SwitchBotCoordinator
from .const import DOMAIN
from .entity import SwitchBotCloudEntity
@@ -32,10 +32,22 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
_attr_name = None
+ def __init__(
+ self,
+ api: SwitchBotAPI,
+ device: Device | Remote,
+ coordinator: SwitchBotCoordinator,
+ ) -> None:
+ """Init devices."""
+ super().__init__(api, device, coordinator)
+ self.__model = device.device_type
+
def _set_attributes(self) -> None:
"""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():
+ self._attr_supported_features = LockEntityFeature.OPEN
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
@@ -45,7 +57,12 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
-
await self.send_api_command(LockCommands.UNLOCK)
self._attr_is_locked = False
self.async_write_ha_state()
+
+ async def async_open(self, **kwargs: Any) -> None:
+ """Latch open the lock."""
+ await self.send_api_command(LockV2Commands.DEADBOLT)
+ self._attr_is_locked = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json
index b07bae88072..2e5813182ff 100644
--- a/homeassistant/components/switchbot_cloud/manifest.json
+++ b/homeassistant/components/switchbot_cloud/manifest.json
@@ -1,12 +1,17 @@
{
"domain": "switchbot_cloud",
"name": "SwitchBot Cloud",
- "codeowners": ["@SeraphicRav", "@laurence-presland", "@Gigatrappeur"],
+ "codeowners": [
+ "@SeraphicRav",
+ "@laurence-presland",
+ "@Gigatrappeur",
+ "@XiaoLing-git"
+ ],
"config_flow": true,
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/switchbot_cloud",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["switchbot_api"],
- "requirements": ["switchbot-api==2.7.0"]
+ "requirements": ["switchbot-api==2.8.0"]
}
diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py
index 163b1653686..ff15b980d5e 100644
--- a/homeassistant/components/switchbot_cloud/sensor.py
+++ b/homeassistant/components/switchbot_cloud/sensor.py
@@ -1,6 +1,10 @@
"""Platform for sensor integration."""
-from switchbot_api import Device, SwitchBotAPI
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from switchbot_api import Device, Remote, SwitchBotAPI
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -14,10 +18,12 @@ from homeassistant.const import (
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
+ UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
@@ -32,6 +38,31 @@ SENSOR_TYPE_CO2 = "CO2"
SENSOR_TYPE_POWER = "power"
SENSOR_TYPE_VOLTAGE = "voltage"
SENSOR_TYPE_CURRENT = "electricCurrent"
+SENSOR_TYPE_USED_ELECTRICITY = "usedElectricity"
+SENSOR_TYPE_LIGHTLEVEL = "lightLevel"
+
+
+RELAY_SWITCH_2PM_SENSOR_TYPE_POWER = "Power"
+RELAY_SWITCH_2PM_SENSOR_TYPE_VOLTAGE = "Voltage"
+RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT = "ElectricCurrent"
+RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY = "UsedElectricity"
+
+
+@dataclass(frozen=True, kw_only=True)
+class SwitchbotCloudSensorEntityDescription(SensorEntityDescription):
+ """Plug Mini Eu UsedElectricity Sensor EntityDescription."""
+
+ value_fn: Callable[[Any], Any] = lambda value: value
+
+
+USED_ELECTRICITY_DESCRIPTION = SwitchbotCloudSensorEntityDescription(
+ key=SENSOR_TYPE_USED_ELECTRICITY,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=2,
+ value_fn=lambda data: (data.get(SENSOR_TYPE_USED_ELECTRICITY) or 0) / 60000,
+)
TEMPERATURE_DESCRIPTION = SensorEntityDescription(
key=SENSOR_TYPE_TEMPERATURE,
@@ -89,6 +120,40 @@ CO2_DESCRIPTION = SensorEntityDescription(
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
)
+RELAY_SWITCH_2PM_POWER_DESCRIPTION = SensorEntityDescription(
+ key=RELAY_SWITCH_2PM_SENSOR_TYPE_POWER,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+)
+
+RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION = SensorEntityDescription(
+ key=RELAY_SWITCH_2PM_SENSOR_TYPE_VOLTAGE,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+)
+
+RELAY_SWITCH_2PM_CURRENT_DESCRIPTION = SensorEntityDescription(
+ key=RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+)
+
+RELAY_SWITCH_2PM_ElECTRICITY_DESCRIPTION = SensorEntityDescription(
+ key=RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+)
+
+LIGHTLEVEL_DESCRIPTION = SensorEntityDescription(
+ key="lightLevel",
+ translation_key="light_level",
+ state_class=SensorStateClass.MEASUREMENT,
+)
+
SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Bot": (BATTERY_DESCRIPTION,),
"Battery Circulator Fan": (BATTERY_DESCRIPTION,),
@@ -120,6 +185,12 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
VOLTAGE_DESCRIPTION,
CURRENT_DESCRIPTION_IN_MA,
),
+ "Plug Mini (EU)": (
+ POWER_DESCRIPTION,
+ VOLTAGE_DESCRIPTION,
+ CURRENT_DESCRIPTION_IN_MA,
+ USED_ELECTRICITY_DESCRIPTION,
+ ),
"Hub 2": (
TEMPERATURE_DESCRIPTION,
HUMIDITY_DESCRIPTION,
@@ -139,10 +210,30 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
+ "Relay Switch 2PM": (
+ RELAY_SWITCH_2PM_POWER_DESCRIPTION,
+ RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION,
+ RELAY_SWITCH_2PM_CURRENT_DESCRIPTION,
+ RELAY_SWITCH_2PM_ElECTRICITY_DESCRIPTION,
+ ),
"Curtain": (BATTERY_DESCRIPTION,),
"Curtain3": (BATTERY_DESCRIPTION,),
"Roller Shade": (BATTERY_DESCRIPTION,),
"Blind Tilt": (BATTERY_DESCRIPTION,),
+ "Hub 3": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ LIGHTLEVEL_DESCRIPTION,
+ ),
+ "Motion Sensor": (BATTERY_DESCRIPTION,),
+ "Contact Sensor": (BATTERY_DESCRIPTION,),
+ "Water Detector": (BATTERY_DESCRIPTION,),
+ "Humidifier": (TEMPERATURE_DESCRIPTION,),
+ "Climate Panel": (
+ TEMPERATURE_DESCRIPTION,
+ HUMIDITY_DESCRIPTION,
+ BATTERY_DESCRIPTION,
+ ),
}
@@ -153,12 +244,25 @@ async def async_setup_entry(
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
-
- async_add_entities(
- SwitchBotCloudSensor(data.api, device, coordinator, description)
- for device, coordinator in data.devices.sensors
- for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]
- )
+ entities: list[SwitchBotCloudSensor] = []
+ for device, coordinator in data.devices.sensors:
+ for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]:
+ if device.device_type == "Relay Switch 2PM":
+ entities.append(
+ SwitchBotCloudRelaySwitch2PMSensor(
+ data.api, device, coordinator, description, "1"
+ )
+ )
+ entities.append(
+ SwitchBotCloudRelaySwitch2PMSensor(
+ data.api, device, coordinator, description, "2"
+ )
+ )
+ else:
+ entities.append(
+ _async_make_entity(data.api, device, coordinator, description)
+ )
+ async_add_entities(entities)
class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity):
@@ -181,3 +285,48 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity):
if not self.coordinator.data:
return
self._attr_native_value = self.coordinator.data.get(self.entity_description.key)
+
+
+class SwitchBotCloudRelaySwitch2PMSensor(SwitchBotCloudSensor):
+ """Representation of a SwitchBot Cloud Relay Switch 2PM sensor entity."""
+
+ def __init__(
+ self,
+ api: SwitchBotAPI,
+ device: Device,
+ coordinator: SwitchBotCoordinator,
+ description: SensorEntityDescription,
+ channel: str,
+ ) -> None:
+ """Initialize SwitchBot Cloud sensor entity."""
+ super().__init__(api, device, coordinator, description)
+
+ self.entity_description = description
+ self._channel = channel
+ self._attr_unique_id = f"{device.device_id}-{description.key}-{channel}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{device.device_name}-channel-{channel}")},
+ manufacturer="SwitchBot",
+ model=device.device_type,
+ model_id="RelaySwitch2PM",
+ name=f"{device.device_name} Channel {channel}",
+ )
+
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
+ if not self.coordinator.data:
+ return
+ self._attr_native_value = self.coordinator.data.get(
+ f"switch{self._channel}{self.entity_description.key.strip()}"
+ )
+
+
+@callback
+def _async_make_entity(
+ api: SwitchBotAPI,
+ device: Device | Remote,
+ coordinator: SwitchBotCoordinator,
+ description: SensorEntityDescription,
+) -> SwitchBotCloudSensor:
+ """Make a SwitchBotCloudSensor or SwitchBotCloudRelaySwitch2PMSensor."""
+ return SwitchBotCloudSensor(api, device, coordinator, description)
diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json
index adb7de00682..928e2e1e01b 100644
--- a/homeassistant/components/switchbot_cloud/strings.json
+++ b/homeassistant/components/switchbot_cloud/strings.json
@@ -31,6 +31,27 @@
}
}
}
+ },
+ "sensor": {
+ "light_level": {
+ "name": "Light level"
+ }
+ },
+ "humidifier": {
+ "evaporative_humidifier": {
+ "state_attributes": {
+ "mode": {
+ "state": {
+ "high": "[%key:common::state::high%]",
+ "medium": "[%key:common::state::medium%]",
+ "low": "[%key:common::state::low%]",
+ "quiet": "Quiet",
+ "target_humidity": "Target humidity",
+ "drying_filter": "Drying filter"
+ }
+ }
+ }
+ }
}
}
}
diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py
index ebe20620d3e..2ca98f928b4 100644
--- a/homeassistant/components/switchbot_cloud/switch.py
+++ b/homeassistant/components/switchbot_cloud/switch.py
@@ -1,5 +1,6 @@
"""Support for SwitchBot switch."""
+import asyncio
from typing import Any
from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI
@@ -7,10 +8,11 @@ from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotA
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
-from .const import DOMAIN
+from .const import AFTER_COMMAND_REFRESH, DOMAIN
from .coordinator import SwitchBotCoordinator
from .entity import SwitchBotCloudEntity
@@ -22,10 +24,19 @@ async def async_setup_entry(
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
- async_add_entities(
- _async_make_entity(data.api, device, coordinator)
- for device, coordinator in data.devices.switches
- )
+ entities: list[SwitchBotCloudSwitch] = []
+ for device, coordinator in data.devices.switches:
+ if device.device_type == "Relay Switch 2PM":
+ entities.append(
+ SwitchBotCloudRelaySwitch2PMSwitch(data.api, device, coordinator, "1")
+ )
+ entities.append(
+ SwitchBotCloudRelaySwitch2PMSwitch(data.api, device, coordinator, "2")
+ )
+ else:
+ entities.append(_async_make_entity(data.api, device, coordinator))
+
+ async_add_entities(entities)
class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity):
@@ -76,6 +87,54 @@ class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch):
self._attr_is_on = self.coordinator.data.get("switchStatus") == 1
+class SwitchBotCloudRelaySwitch2PMSwitch(SwitchBotCloudSwitch):
+ """Representation of a SwitchBot relay switch."""
+
+ def __init__(
+ self,
+ api: SwitchBotAPI,
+ device: Device | Remote,
+ coordinator: SwitchBotCoordinator,
+ channel: str,
+ ) -> None:
+ """Init SwitchBotCloudRelaySwitch2PMSwitch."""
+ super().__init__(api, device, coordinator)
+ self._channel = channel
+ self._device_id = device.device_id
+ self._attr_unique_id = f"{device.device_id}-{channel}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, f"{device.device_name}-channel-{channel}")},
+ manufacturer="SwitchBot",
+ model=device.device_type,
+ model_id="RelaySwitch2PM",
+ name=f"{device.device_name} Channel {channel}",
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self._api.send_command(
+ self._device_id, command=CommonCommands.ON, parameters=self._channel
+ )
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self._api.send_command(
+ self._device_id, command=CommonCommands.OFF, parameters=self._channel
+ )
+ await asyncio.sleep(AFTER_COMMAND_REFRESH)
+ await self.coordinator.async_request_refresh()
+
+ def _set_attributes(self) -> None:
+ """Set attributes from coordinator data."""
+ if self.coordinator.data is None:
+ return
+ self._attr_is_on = (
+ self.coordinator.data.get(f"switch{self._channel}Status") == 1
+ )
+
+
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
@@ -83,13 +142,11 @@ def _async_make_entity(
"""Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch."""
if isinstance(device, Remote):
return SwitchBotCloudRemoteSwitch(api, device, coordinator)
+ if device.device_type in ["Relay Switch 1PM", "Relay Switch 1", "Plug Mini (EU)"]:
+ return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator)
if "Plug" in device.device_type:
return SwitchBotCloudPlugSwitch(api, device, coordinator)
- if device.device_type in [
- "Relay Switch 1PM",
- "Relay Switch 1",
- ]:
- return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator)
if "Bot" in device.device_type:
return SwitchBotCloudSwitch(api, device, coordinator)
+
raise NotImplementedError(f"Unsupported device type: {device.device_type}")
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index 1e602061c2c..1771716b64d 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -63,12 +63,14 @@ async def async_setup_entry(
SERVICE_SET_AUTO_OFF_NAME,
SERVICE_SET_AUTO_OFF_SCHEMA,
"async_set_auto_off_service",
+ entity_device_classes=(SwitchDeviceClass.SWITCH,),
)
platform.async_register_entity_service(
SERVICE_TURN_ON_WITH_TIMER_NAME,
SERVICE_TURN_ON_WITH_TIMER_SCHEMA,
"async_turn_on_with_timer_service",
+ entity_device_classes=(SwitchDeviceClass.SWITCH,),
)
@callback
@@ -135,22 +137,6 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity):
self._attr_is_on = self.control_result = False
self.async_write_ha_state()
- async def async_set_auto_off_service(self, auto_off: timedelta) -> None:
- """Use for handling setting device auto-off service calls."""
- _LOGGER.warning(
- "Service '%s' is not supported by %s",
- SERVICE_SET_AUTO_OFF_NAME,
- self.coordinator.name,
- )
-
- async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None:
- """Use for turning device on with a timer service calls."""
- _LOGGER.warning(
- "Service '%s' is not supported by %s",
- SERVICE_TURN_ON_WITH_TIMER_NAME,
- self.coordinator.name,
- )
-
class SwitcherPowerPlugSwitchEntity(SwitcherBaseSwitchEntity):
"""Representation of a Switcher power plug switch entity."""
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index 7146d42136e..d5254798072 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from itertools import chain
import logging
+from typing import TYPE_CHECKING
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
from synology_dsm.api.surveillance_station.camera import SynoCamera
@@ -177,10 +178,12 @@ async def async_remove_config_entry_device(
"""Remove synology_dsm config entry from a device."""
data = entry.runtime_data
api = data.api
- assert api.information is not None
+ if TYPE_CHECKING:
+ assert api.information is not None
serial = api.information.serial
storage = api.storage
- assert storage is not None
+ if TYPE_CHECKING:
+ assert storage is not None
all_cameras: list[SynoCamera] = []
if api.surveillance_station is not None:
# get_all_cameras does not do I/O
diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py
index 1ae5fa90760..3af87f9756d 100644
--- a/homeassistant/components/synology_dsm/binary_sensor.py
+++ b/homeassistant/components/synology_dsm/binary_sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from typing import TYPE_CHECKING
from synology_dsm.api.core.security import SynoCoreSecurity
from synology_dsm.api.storage.storage import SynoStorage
@@ -68,7 +69,8 @@ async def async_setup_entry(
data = entry.runtime_data
api = data.api
coordinator = data.coordinator_central
- assert api.storage is not None
+ if TYPE_CHECKING:
+ assert api.storage is not None
entities: list[SynoDSMSecurityBinarySensor | SynoDSMStorageBinarySensor] = [
SynoDSMSecurityBinarySensor(api, coordinator, description)
@@ -121,7 +123,8 @@ class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor):
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return security checks details."""
- assert self._api.security is not None
+ if TYPE_CHECKING:
+ assert self._api.security is not None
return self._api.security.status_by_check
diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py
index 79297b1f1b4..9c99f3a4c2a 100644
--- a/homeassistant/components/synology_dsm/button.py
+++ b/homeassistant/components/synology_dsm/button.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -72,8 +72,9 @@ class SynologyDSMButton(ButtonEntity):
"""Initialize the Synology DSM binary_sensor entity."""
self.entity_description = description
self.syno_api = api
- assert api.network is not None
- assert api.information is not None
+ if TYPE_CHECKING:
+ assert api.network is not None
+ assert api.information is not None
self._attr_name = f"{api.network.hostname} {description.name}"
self._attr_unique_id = f"{api.information.serial}_{description.key}"
self._attr_device_info = DeviceInfo(
@@ -82,7 +83,8 @@ class SynologyDSMButton(ButtonEntity):
async def async_press(self) -> None:
"""Triggers the Synology DSM button press service."""
- assert self.syno_api.network is not None
+ if TYPE_CHECKING:
+ assert self.syno_api.network is not None
LOGGER.debug(
"Trigger %s for %s",
self.entity_description.key,
diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py
index f393b8efb55..56183804e5f 100644
--- a/homeassistant/components/synology_dsm/camera.py
+++ b/homeassistant/components/synology_dsm/camera.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
+from typing import TYPE_CHECKING
from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation
from synology_dsm.exceptions import (
@@ -94,7 +95,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
def device_info(self) -> DeviceInfo:
"""Return the device information."""
information = self._api.information
- assert information is not None
+ if TYPE_CHECKING:
+ assert information is not None
return DeviceInfo(
identifiers={(DOMAIN, f"{information.serial}_{self.camera_data.id}")},
name=self.camera_data.name,
@@ -129,7 +131,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
_LOGGER.debug("Update stream URL for camera %s", self.camera_data.name)
self.stream.update_source(url)
- assert self.platform.config_entry
+ if TYPE_CHECKING:
+ assert self.platform.config_entry
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -153,7 +156,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
)
if not self.available:
return None
- assert self._api.surveillance_station is not None
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
try:
return await self._api.surveillance_station.get_camera_image(
self.entity_description.camera_id, self.snapshot_quality
@@ -187,7 +191,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
"SynoDSMCamera.enable_motion_detection(%s)",
self.camera_data.name,
)
- assert self._api.surveillance_station is not None
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
await self._api.surveillance_station.enable_motion_detection(
self.entity_description.camera_id
)
@@ -198,7 +203,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
"SynoDSMCamera.disable_motion_detection(%s)",
self.camera_data.name,
)
- assert self._api.surveillance_station is not None
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
await self._api.surveillance_station.disable_motion_detection(
self.entity_description.camera_id
)
diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py
index dd97dedf65e..c2fa275c7de 100644
--- a/homeassistant/components/synology_dsm/coordinator.py
+++ b/homeassistant/components/synology_dsm/coordinator.py
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import Any, Concatenate
+from typing import TYPE_CHECKING, Any, Concatenate
from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import (
@@ -110,14 +110,16 @@ class SynologyDSMSwitchUpdateCoordinator(
async def async_setup(self) -> None:
"""Set up the coordinator initial data."""
info = await self.api.dsm.surveillance_station.get_info()
- assert info is not None
+ if TYPE_CHECKING:
+ assert info is not None
self.version = info["data"]["CMSMinVersion"]
@async_re_login_on_expired
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch all data from api."""
surveillance_station = self.api.surveillance_station
- assert surveillance_station is not None
+ if TYPE_CHECKING:
+ assert surveillance_station is not None
return {
"switches": {
"home_mode": bool(await surveillance_station.get_home_mode_status())
@@ -161,7 +163,8 @@ class SynologyDSMCameraUpdateCoordinator(
async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]:
"""Fetch all camera data from api."""
surveillance_station = self.api.surveillance_station
- assert surveillance_station is not None
+ if TYPE_CHECKING:
+ assert surveillance_station is not None
current_data: dict[int, SynoCamera] = {
camera.id: camera for camera in surveillance_station.get_all_cameras()
}
diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py
index 85269b9c480..3ffbcce5466 100644
--- a/homeassistant/components/synology_dsm/entity.py
+++ b/homeassistant/components/synology_dsm/entity.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any
+from typing import TYPE_CHECKING, Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -47,8 +47,9 @@ class SynologyDSMBaseEntity[_CoordinatorT: SynologyDSMUpdateCoordinator[Any]](
self._api = api
information = api.information
network = api.network
- assert information is not None
- assert network is not None
+ if TYPE_CHECKING:
+ assert information is not None
+ assert network is not None
self._attr_unique_id: str = (
f"{information.serial}_{description.api_key}:{description.key}"
@@ -94,14 +95,17 @@ class SynologyDSMDeviceEntity(
information = api.information
network = api.network
external_usb = api.external_usb
- assert information is not None
- assert storage is not None
- assert network is not None
+ if TYPE_CHECKING:
+ assert information is not None
+ assert storage is not None
+ assert network is not None
if "volume" in description.key:
- assert self._device_id is not None
+ if TYPE_CHECKING:
+ assert self._device_id is not None
volume = storage.get_volume(self._device_id)
- assert volume is not None
+ if TYPE_CHECKING:
+ assert volume is not None
# Volume does not have a name
self._device_name = volume["id"].replace("_", " ").capitalize()
self._device_manufacturer = "Synology"
@@ -114,17 +118,20 @@ class SynologyDSMDeviceEntity(
.replace("shr", "SHR")
)
elif "disk" in description.key:
- assert self._device_id is not None
+ if TYPE_CHECKING:
+ assert self._device_id is not None
disk = storage.get_disk(self._device_id)
- assert disk is not None
+ if TYPE_CHECKING:
+ assert disk is not None
self._device_name = disk["name"]
self._device_manufacturer = disk["vendor"]
self._device_model = disk["model"].strip()
self._device_firmware = disk["firm"]
self._device_type = disk["diskType"]
elif "device" in description.key:
- assert self._device_id is not None
- assert external_usb is not None
+ if TYPE_CHECKING:
+ assert self._device_id is not None
+ assert external_usb is not None
for device in external_usb.get_devices.values():
if device.device_name == self._device_id:
self._device_name = device.device_name
@@ -133,8 +140,9 @@ class SynologyDSMDeviceEntity(
self._device_type = device.device_type
break
elif "partition" in description.key:
- assert self._device_id is not None
- assert external_usb is not None
+ if TYPE_CHECKING:
+ assert self._device_id is not None
+ assert external_usb is not None
for device in external_usb.get_devices.values():
for partition in device.device_partitions.values():
if partition.partition_title == self._device_id:
diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py
index 7fafe1fecb3..94edef603ce 100644
--- a/homeassistant/components/synology_dsm/media_source.py
+++ b/homeassistant/components/synology_dsm/media_source.py
@@ -4,15 +4,15 @@ from __future__ import annotations
from logging import getLogger
import mimetypes
+from typing import TYPE_CHECKING
from aiohttp import web
from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem
from synology_dsm.exceptions import SynologyDSMException
from homeassistant.components import http
-from homeassistant.components.media_player import MediaClass
+from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
- BrowseError,
BrowseMediaSource,
MediaSource,
MediaSourceItem,
@@ -122,9 +122,11 @@ class SynologyPhotosMediaSource(MediaSource):
DOMAIN, identifier.unique_id
)
)
- assert entry
+ if TYPE_CHECKING:
+ assert entry
diskstation = entry.runtime_data
- assert diskstation.api.photos is not None
+ if TYPE_CHECKING:
+ assert diskstation.api.photos is not None
if identifier.album_id is None:
# Get Albums
@@ -132,7 +134,8 @@ class SynologyPhotosMediaSource(MediaSource):
albums = await diskstation.api.photos.get_albums()
except SynologyDSMException:
return []
- assert albums is not None
+ if TYPE_CHECKING:
+ assert albums is not None
ret = [
BrowseMediaSource(
@@ -191,7 +194,8 @@ class SynologyPhotosMediaSource(MediaSource):
)
except SynologyDSMException:
return []
- assert album_items is not None
+ if TYPE_CHECKING:
+ assert album_items is not None
ret = []
for album_item in album_items:
@@ -250,7 +254,8 @@ class SynologyPhotosMediaSource(MediaSource):
self, item: SynoPhotosItem, diskstation: SynologyDSMData
) -> str | None:
"""Get thumbnail."""
- assert diskstation.api.photos is not None
+ if TYPE_CHECKING:
+ assert diskstation.api.photos is not None
try:
thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item)
@@ -291,9 +296,11 @@ class SynologyDsmMediaView(http.HomeAssistantView):
DOMAIN, source_dir_id
)
)
- assert entry
+ if TYPE_CHECKING:
+ assert entry
diskstation = entry.runtime_data
- assert diskstation.api.photos is not None
+ if TYPE_CHECKING:
+ assert diskstation.api.photos is not None
item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase)
try:
if passphrase:
diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py
index 613938f078f..dd46fa33c3a 100644
--- a/homeassistant/components/synology_dsm/sensor.py
+++ b/homeassistant/components/synology_dsm/sensor.py
@@ -4,9 +4,12 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
-from typing import cast
+from typing import TYPE_CHECKING, cast
-from synology_dsm.api.core.external_usb import SynoCoreExternalUSB
+from synology_dsm.api.core.external_usb import (
+ SynoCoreExternalUSB,
+ SynoCoreExternalUSBDevice,
+)
from synology_dsm.api.core.utilization import SynoCoreUtilization
from synology_dsm.api.dsm.information import SynoDSMInformation
from synology_dsm.api.storage.storage import SynoStorage
@@ -342,15 +345,44 @@ async def async_setup_entry(
api = data.api
coordinator = data.coordinator_central
storage = api.storage
- assert storage is not None
- external_usb = api.external_usb
+ if TYPE_CHECKING:
+ assert storage is not None
+ known_usb_devices: set[str] = set()
- entities: list[
- SynoDSMUtilSensor
- | SynoDSMStorageSensor
- | SynoDSMInfoSensor
- | SynoDSMExternalUSBSensor
- ] = [
+ def _check_usb_devices() -> None:
+ """Check for new USB devices during and after initial setup."""
+ if api.external_usb is not None and api.external_usb.get_devices:
+ current_usb_devices: set[str] = {
+ device.device_name for device in api.external_usb.get_devices.values()
+ }
+ new_usb_devices = current_usb_devices - known_usb_devices
+ if new_usb_devices:
+ known_usb_devices.update(new_usb_devices)
+ external_devices: list[SynoCoreExternalUSBDevice] = [
+ device
+ for device in api.external_usb.get_devices.values()
+ if device.device_name in new_usb_devices
+ ]
+ new_usb_entities: list[SynoDSMExternalUSBSensor] = [
+ SynoDSMExternalUSBSensor(
+ api, coordinator, description, device.device_name
+ )
+ for device in entry.data.get(CONF_DEVICES, external_devices)
+ for description in EXTERNAL_USB_DISK_SENSORS
+ ]
+ new_usb_entities.extend(
+ [
+ SynoDSMExternalUSBSensor(
+ api, coordinator, description, partition.partition_title
+ )
+ for device in entry.data.get(CONF_DEVICES, external_devices)
+ for partition in device.device_partitions.values()
+ for description in EXTERNAL_USB_PARTITION_SENSORS
+ ]
+ )
+ async_add_entities(new_usb_entities)
+
+ entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [
SynoDSMUtilSensor(api, coordinator, description)
for description in UTILISATION_SENSORS
]
@@ -375,32 +407,6 @@ async def async_setup_entry(
]
)
- # Handle all external usb
- if external_usb is not None and external_usb.get_devices:
- entities.extend(
- [
- SynoDSMExternalUSBSensor(
- api, coordinator, description, device.device_name
- )
- for device in entry.data.get(
- CONF_DEVICES, external_usb.get_devices.values()
- )
- for description in EXTERNAL_USB_DISK_SENSORS
- ]
- )
- entities.extend(
- [
- SynoDSMExternalUSBSensor(
- api, coordinator, description, partition.partition_title
- )
- for device in entry.data.get(
- CONF_DEVICES, external_usb.get_devices.values()
- )
- for partition in device.device_partitions.values()
- for description in EXTERNAL_USB_PARTITION_SENSORS
- ]
- )
-
entities.extend(
[
SynoDSMInfoSensor(api, coordinator, description)
@@ -408,6 +414,9 @@ async def async_setup_entry(
]
)
+ _check_usb_devices()
+ entry.async_on_unload(coordinator.async_add_listener(_check_usb_devices))
+
async_add_entities(entities)
@@ -496,7 +505,8 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor):
def native_value(self) -> StateType:
"""Return the state."""
external_usb = self._api.external_usb
- assert external_usb is not None
+ if TYPE_CHECKING:
+ assert external_usb is not None
if "device" in self.entity_description.key:
for device in external_usb.get_devices.values():
if device.device_name == self._device_id:
@@ -515,6 +525,22 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor):
return attr # type: ignore[no-any-return]
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ external_usb = self._api.external_usb
+ assert external_usb is not None
+ if "device" in self.entity_description.key:
+ for device in external_usb.get_devices.values():
+ if device.device_name == self._device_id:
+ return super().available
+ elif "partition" in self.entity_description.key:
+ for device in external_usb.get_devices.values():
+ for partition in device.device_partitions.values():
+ if partition.partition_title == self._device_id:
+ return super().available
+ return False
+
class SynoDSMInfoSensor(SynoDSMSensor):
"""Representation a Synology information sensor."""
diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py
index 9522361d500..ad0615eaa56 100644
--- a/homeassistant/components/synology_dsm/services.py
+++ b/homeassistant/components/synology_dsm/services.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import cast
+from typing import TYPE_CHECKING, cast
from synology_dsm.exceptions import SynologyDSMException
@@ -27,7 +27,8 @@ async def _service_handler(call: ServiceCall) -> None:
entry: SynologyDSMConfigEntry | None = (
call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
)
- assert entry
+ if TYPE_CHECKING:
+ assert entry
dsm_device = entry.runtime_data
elif len(dsm_devices) == 1:
dsm_device = next(iter(dsm_devices.values()))
diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py
index 91863ff3a26..8be6dedd8ca 100644
--- a/homeassistant/components/synology_dsm/switch.py
+++ b/homeassistant/components/synology_dsm/switch.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
@@ -45,7 +45,8 @@ async def async_setup_entry(
"""Set up the Synology NAS switch."""
data = entry.runtime_data
if coordinator := data.coordinator_switches:
- assert coordinator.version is not None
+ if TYPE_CHECKING:
+ assert coordinator.version is not None
async_add_entities(
SynoDSMSurveillanceHomeModeToggle(
data.api, coordinator.version, coordinator, description
@@ -79,8 +80,9 @@ class SynoDSMSurveillanceHomeModeToggle(
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on Home mode."""
- assert self._api.surveillance_station is not None
- assert self._api.information
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
+ assert self._api.information
_LOGGER.debug(
"SynoDSMSurveillanceHomeModeToggle.turn_on(%s)",
self._api.information.serial,
@@ -90,8 +92,9 @@ class SynoDSMSurveillanceHomeModeToggle(
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off Home mode."""
- assert self._api.surveillance_station is not None
- assert self._api.information
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
+ assert self._api.information
_LOGGER.debug(
"SynoDSMSurveillanceHomeModeToggle.turn_off(%s)",
self._api.information.serial,
@@ -107,9 +110,10 @@ class SynoDSMSurveillanceHomeModeToggle(
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
- assert self._api.surveillance_station is not None
- assert self._api.information is not None
- assert self._api.network is not None
+ if TYPE_CHECKING:
+ assert self._api.surveillance_station is not None
+ assert self._api.information is not None
+ assert self._api.network is not None
return DeviceInfo(
identifiers={
(
diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py
index 3048a38cb9c..6b421f639e7 100644
--- a/homeassistant/components/synology_dsm/update.py
+++ b/homeassistant/components/synology_dsm/update.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Final
+from typing import TYPE_CHECKING, Final
from synology_dsm.api.core.upgrade import SynoCoreUpgrade
from yarl import URL
@@ -63,13 +63,15 @@ class SynoDSMUpdateEntity(
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
- assert self._api.information is not None
+ if TYPE_CHECKING:
+ assert self._api.information is not None
return self._api.information.version_string
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
- assert self._api.upgrade is not None
+ if TYPE_CHECKING:
+ assert self._api.upgrade is not None
if not self._api.upgrade.update_available:
return self.installed_version
return self._api.upgrade.available_version
@@ -77,8 +79,9 @@ class SynoDSMUpdateEntity(
@property
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
- assert self._api.information is not None
- assert self._api.upgrade is not None
+ if TYPE_CHECKING:
+ assert self._api.information is not None
+ assert self._api.upgrade is not None
if (details := self._api.upgrade.available_version_details) is None:
return None
diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json
index c19f36f14dd..d2d9bb6e657 100644
--- a/homeassistant/components/system_bridge/manifest.json
+++ b/homeassistant/components/system_bridge/manifest.json
@@ -9,6 +9,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
- "requirements": ["systembridgeconnector==4.1.10"],
+ "requirements": ["systembridgeconnector==5.1.0"],
"zeroconf": ["_system-bridge._tcp.local."]
}
diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py
index d9226e7de6e..8ad3ede3960 100644
--- a/homeassistant/components/system_bridge/sensor.py
+++ b/homeassistant/components/system_bridge/sensor.py
@@ -332,6 +332,16 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
icon="mdi:percent",
value=lambda data: data.cpu.usage,
),
+ SystemBridgeSensorEntityDescription(
+ key="power_usage",
+ translation_key="power_usage",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_display_precision=2,
+ icon="mdi:power-plug",
+ value=lambda data: data.system.power_usage,
+ ),
SystemBridgeSensorEntityDescription(
key="version",
translation_key="version",
@@ -568,7 +578,6 @@ async def async_setup_entry(
key=f"gpu_{gpu.id}_power_usage",
name=f"{gpu.name} power usage",
entity_registry_enabled_default=False,
- device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
value=lambda data, k=index: gpu_power_usage(data, k),
diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json
index 1c079c1ef0c..0cca826684a 100644
--- a/homeassistant/components/system_bridge/strings.json
+++ b/homeassistant/components/system_bridge/strings.json
@@ -78,6 +78,9 @@
"processes": {
"name": "Processes"
},
+ "power_usage": {
+ "name": "Power usage"
+ },
"load": {
"name": "Load"
},
diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py
index 2776feba272..25027048c72 100644
--- a/homeassistant/components/systemmonitor/__init__.py
+++ b/homeassistant/components/systemmonitor/__init__.py
@@ -50,13 +50,15 @@ async def async_setup_entry(
_LOGGER.debug("disk arguments to be added: %s", disk_arguments)
coordinator: SystemMonitorCoordinator = SystemMonitorCoordinator(
- hass, entry, psutil_wrapper, disk_arguments
+ hass,
+ entry,
+ psutil_wrapper,
+ disk_arguments,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -67,11 +69,6 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def update_listener(hass: HomeAssistant, entry: SystemMonitorConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_migrate_entry(
hass: HomeAssistant, entry: SystemMonitorConfigEntry
) -> bool:
diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py
index 3968e94ec03..ad84e727129 100644
--- a/homeassistant/components/systemmonitor/binary_sensor.py
+++ b/homeassistant/components/systemmonitor/binary_sensor.py
@@ -9,8 +9,6 @@ import logging
import sys
from typing import Literal
-from psutil import NoSuchProcess
-
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
@@ -25,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import SystemMonitorConfigEntry
-from .const import CONF_PROCESS, DOMAIN
+from .const import CONF_PROCESS, DOMAIN, PROCESS_ERRORS
from .coordinator import SystemMonitorCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -59,12 +57,8 @@ def get_process(entity: SystemMonitorSensor) -> bool:
if entity.argument == proc.name():
state = True
break
- except NoSuchProcess as err:
- _LOGGER.warning(
- "Failed to load process with ID: %s, old name: %s",
- err.pid,
- err.name,
- )
+ except PROCESS_ERRORS:
+ continue
return state
diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py
index 4be31f6944c..66c4913f19e 100644
--- a/homeassistant/components/systemmonitor/config_flow.py
+++ b/homeassistant/components/systemmonitor/config_flow.py
@@ -92,6 +92,8 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
+
VERSION = 1
MINOR_VERSION = 3
diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py
index 798cb82f8ef..72fd3384687 100644
--- a/homeassistant/components/systemmonitor/const.py
+++ b/homeassistant/components/systemmonitor/const.py
@@ -1,5 +1,7 @@
"""Constants for System Monitor."""
+from psutil import AccessDenied, Error, NoSuchProcess, TimeoutExpired, ZombieProcess
+
DOMAIN = "systemmonitor"
CONF_INDEX = "index"
@@ -14,6 +16,8 @@ NET_IO_TYPES = [
"packets_out",
]
+PROCESS_ERRORS = (NoSuchProcess, AccessDenied, Error, TimeoutExpired, ZombieProcess)
+
# There might be additional keys to be added for different
# platforms / hardware combinations.
# Taken from last version of "glances" integration before they moved to
diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py
index 03b769ee2e2..5cbc81eba6b 100644
--- a/homeassistant/components/systemmonitor/coordinator.py
+++ b/homeassistant/components/systemmonitor/coordinator.py
@@ -12,11 +12,14 @@ from psutil import Process
from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap
import psutil_home_assistant as ha_psutil
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from homeassistant.util import dt as dt_util
+from .const import CONF_PROCESS, PROCESS_ERRORS
+
if TYPE_CHECKING:
from . import SystemMonitorConfigEntry
@@ -37,6 +40,7 @@ class SensorData:
boot_time: datetime
processes: list[Process]
temperatures: dict[str, list[shwtemp]]
+ process_fds: dict[str, int]
def as_dict(self) -> dict[str, Any]:
"""Return as dict."""
@@ -63,6 +67,7 @@ class SensorData:
"boot_time": str(self.boot_time),
"processes": str(self.processes),
"temperatures": temperatures,
+ "process_fds": self.process_fds,
}
@@ -83,6 +88,8 @@ class VirtualMemory(NamedTuple):
class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
"""A System monitor Data Update Coordinator."""
+ config_entry: SystemMonitorConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
@@ -156,6 +163,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
boot_time=_data["boot_time"],
processes=_data["processes"],
temperatures=_data["temperatures"],
+ process_fds=_data["process_fds"],
)
def update_data(self) -> dict[str, Any]:
@@ -203,11 +211,41 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
self.boot_time = dt_util.utc_from_timestamp(self._psutil.boot_time())
_LOGGER.debug("boot time: %s", self.boot_time)
- processes = None
+ selected_processes: list[Process] = []
+ process_fds: dict[str, int] = {}
if self.update_subscribers[("processes", "")] or self._initial_update:
processes = self._psutil.process_iter()
_LOGGER.debug("processes: %s", processes)
- processes = list(processes)
+ user_options: list[str] = self.config_entry.options.get(
+ BINARY_SENSOR_DOMAIN, {}
+ ).get(CONF_PROCESS, [])
+ for process in processes:
+ try:
+ if (process_name := process.name()) in user_options:
+ selected_processes.append(process)
+ process_fds[process_name] = (
+ process_fds.get(process_name, 0) + process.num_fds()
+ )
+
+ except PROCESS_ERRORS as err:
+ if not hasattr(err, "pid") or not hasattr(err, "name"):
+ _LOGGER.warning(
+ "Failed to load process: %s",
+ str(err),
+ )
+ else:
+ _LOGGER.warning(
+ "Failed to load process with ID: %s, old name: %s",
+ err.pid,
+ err.name,
+ )
+ continue
+ except OSError as err:
+ _LOGGER.warning(
+ "OS error getting file descriptor count for process %s: %s",
+ process.pid if hasattr(process, "pid") else "unknown",
+ err,
+ )
temps: dict[str, list[shwtemp]] = {}
if self.update_subscribers[("temperatures", "")] or self._initial_update:
@@ -224,6 +262,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
"io_counters": io_counters,
"addresses": addresses,
"boot_time": self.boot_time,
- "processes": processes,
+ "processes": selected_processes,
"temperatures": temps,
+ "process_fds": process_fds,
}
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index e70bccf0833..1b7764eac00 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -37,7 +37,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import SystemMonitorConfigEntry
-from .const import DOMAIN, NET_IO_TYPES
+from .binary_sensor import BINARY_SENSOR_DOMAIN
+from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES
from .coordinator import SystemMonitorCoordinator
from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature
@@ -54,6 +55,14 @@ SENSOR_TYPE_MANDATORY_ARG = 4
SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update"
+SENSORS_NO_ARG = ("load_", "memory_", "processor_use", "swap_", "last_boot")
+SENSORS_WITH_ARG = {
+ "disk_": "disk_arguments",
+ "ipv": "network_arguments",
+ **dict.fromkeys(NET_IO_TYPES, "network_arguments"),
+ "process_num_fds": "processes",
+}
+
@lru_cache
def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]:
@@ -118,6 +127,12 @@ def get_ip_address(
return None
+def get_process_num_fds(entity: SystemMonitorSensor) -> int | None:
+ """Return the number of file descriptors opened by the process."""
+ process_fds = entity.coordinator.data.process_fds
+ return process_fds.get(entity.argument)
+
+
@dataclass(frozen=True, kw_only=True)
class SysMonitorSensorEntityDescription(SensorEntityDescription):
"""Describes System Monitor sensor entities."""
@@ -369,6 +384,16 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = {
value_fn=lambda entity: entity.coordinator.data.swap.percent,
add_to_update=lambda entity: ("swap", ""),
),
+ "process_num_fds": SysMonitorSensorEntityDescription(
+ key="process_num_fds",
+ translation_key="process_num_fds",
+ placeholder="process",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ mandatory_arg=True,
+ value_fn=get_process_num_fds,
+ add_to_update=lambda entity: ("processes", ""),
+ ),
}
@@ -420,107 +445,32 @@ async def async_setup_entry(
startup_arguments = await hass.async_add_executor_job(get_arguments)
startup_arguments["cpu_temperature"] = cpu_temperature
+ startup_arguments["processes"] = entry.options.get(BINARY_SENSOR_DOMAIN, {}).get(
+ CONF_PROCESS, []
+ )
_LOGGER.debug("Setup from options %s", entry.options)
-
for _type, sensor_description in SENSOR_TYPES.items():
- if _type.startswith("disk_"):
- for argument in startup_arguments["disk_arguments"]:
- is_enabled = check_legacy_resource(
- f"{_type}_{argument}", legacy_resources
- )
- if (_add := slugify(f"{_type}_{argument}")) not in loaded_resources:
- loaded_resources.add(_add)
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
+ for sensor_type, sensor_argument in SENSORS_WITH_ARG.items():
+ if _type.startswith(sensor_type):
+ for argument in startup_arguments[sensor_argument]:
+ is_enabled = check_legacy_resource(
+ f"{_type}_{argument}", legacy_resources
+ )
+ if (_add := slugify(f"{_type}_{argument}")) not in loaded_resources:
+ loaded_resources.add(_add)
+ entities.append(
+ SystemMonitorSensor(
+ coordinator,
+ sensor_description,
+ entry.entry_id,
+ argument,
+ is_enabled,
+ )
)
- )
- continue
+ continue
- if _type.startswith("ipv"):
- for argument in startup_arguments["network_arguments"]:
- is_enabled = check_legacy_resource(
- f"{_type}_{argument}", legacy_resources
- )
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
- continue
-
- if _type == "last_boot":
- argument = ""
- is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
- continue
-
- if _type.startswith("load_"):
- argument = ""
- is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
- continue
-
- if _type.startswith("memory_"):
- argument = ""
- is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
-
- if _type in NET_IO_TYPES:
- for argument in startup_arguments["network_arguments"]:
- is_enabled = check_legacy_resource(
- f"{_type}_{argument}", legacy_resources
- )
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
- continue
-
- if _type == "processor_use":
+ if _type.startswith(SENSORS_NO_ARG):
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
loaded_resources.add(slugify(f"{_type}_{argument}"))
@@ -553,20 +503,6 @@ async def async_setup_entry(
)
continue
- if _type.startswith("swap_"):
- argument = ""
- is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- is_enabled,
- )
- )
-
# Ensure legacy imported disk_* resources are loaded if they are not part
# of mount points automatically discovered
for resource in legacy_resources:
diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json
index 134fe390357..442b9f60790 100644
--- a/homeassistant/components/systemmonitor/strings.json
+++ b/homeassistant/components/systemmonitor/strings.json
@@ -100,6 +100,9 @@
},
"swap_use_percent": {
"name": "Swap usage"
+ },
+ "process_num_fds": {
+ "name": "Open file descriptors {process}"
}
}
}
diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py
index 2a4b889bdde..dec0508bb64 100644
--- a/homeassistant/components/systemmonitor/util.py
+++ b/homeassistant/components/systemmonitor/util.py
@@ -21,12 +21,6 @@ def get_all_disk_mounts(
"""Return all disk mount points on system."""
disks: set[str] = set()
for part in psutil_wrapper.psutil.disk_partitions(all=True):
- if os.name == "nt":
- if "cdrom" in part.opts or part.fstype == "":
- # skip cd-rom drives with no disk in it; they may raise
- # ENOENT, pop-up a Windows GUI error for a non-ready
- # partition or just hang.
- continue
if part.fstype in SKIP_DISK_TYPES:
# Ignore disks which are memory
continue
diff --git a/homeassistant/components/tag/manifest.json b/homeassistant/components/tag/manifest.json
index 738e7f7e744..d7b695b6844 100644
--- a/homeassistant/components/tag/manifest.json
+++ b/homeassistant/components/tag/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "tag",
"name": "Tags",
- "codeowners": ["@balloob", "@dmulcahey"],
+ "codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/tag",
"integration_type": "entity",
"quality_scale": "internal"
diff --git a/homeassistant/components/tasmota/camera.py b/homeassistant/components/tasmota/camera.py
new file mode 100644
index 00000000000..beacb23504b
--- /dev/null
+++ b/homeassistant/components/tasmota/camera.py
@@ -0,0 +1,110 @@
+"""Support for Tasmota Camera."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+import aiohttp
+from hatasmota import camera as tasmota_camera
+from hatasmota.entity import TasmotaEntity as HATasmotaEntity
+from hatasmota.models import DiscoveryHashType
+
+from homeassistant.components import camera
+from homeassistant.components.camera import Camera
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import (
+ async_aiohttp_proxy_web,
+ async_get_clientsession,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DATA_REMOVE_DISCOVER_COMPONENT
+from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
+from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaEntity
+
+TIMEOUT = 10
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Tasmota light dynamically through discovery."""
+
+ @callback
+ def async_discover(
+ tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType
+ ) -> None:
+ """Discover and add a Tasmota camera."""
+ async_add_entities(
+ [
+ TasmotaCamera(
+ tasmota_entity=tasmota_entity, discovery_hash=discovery_hash
+ )
+ ]
+ )
+
+ hass.data[DATA_REMOVE_DISCOVER_COMPONENT.format(camera.DOMAIN)] = (
+ async_dispatcher_connect(
+ hass,
+ TASMOTA_DISCOVERY_ENTITY_NEW.format(camera.DOMAIN),
+ async_discover,
+ )
+ )
+
+
+class TasmotaCamera(
+ TasmotaAvailability,
+ TasmotaDiscoveryUpdate,
+ TasmotaEntity,
+ Camera,
+):
+ """Representation of a Tasmota Camera."""
+
+ _tasmota_entity: tasmota_camera.TasmotaCamera
+
+ def __init__(self, **kwds: Any) -> None:
+ """Initialize."""
+ super().__init__(**kwds)
+ Camera.__init__(self)
+
+ async def async_camera_image(
+ self, width: int | None = None, height: int | None = None
+ ) -> bytes | None:
+ """Return a still image response from the camera."""
+
+ websession = async_get_clientsession(self.hass)
+ try:
+ async with asyncio.timeout(TIMEOUT):
+ response = await self._tasmota_entity.get_still_image_stream(websession)
+ return await response.read()
+
+ except TimeoutError as err:
+ raise HomeAssistantError(
+ f"Timeout getting camera image from {self.name}: {err}"
+ ) from err
+
+ except aiohttp.ClientError as err:
+ raise HomeAssistantError(
+ f"Error getting new camera image from {self.name}: {err}"
+ ) from err
+
+ return None
+
+ async def handle_async_mjpeg_stream(
+ self, request: aiohttp.web.Request
+ ) -> aiohttp.web.StreamResponse | None:
+ """Generate an HTTP MJPEG stream from the camera."""
+ # connect to stream
+ websession = async_get_clientsession(self.hass)
+ stream_coro = self._tasmota_entity.get_mjpeg_stream(websession)
+
+ return await async_aiohttp_proxy_web(self.hass, request, stream_coro)
diff --git a/homeassistant/components/tasmota/const.py b/homeassistant/components/tasmota/const.py
index 1a2cb431a0b..fe1f325e94c 100644
--- a/homeassistant/components/tasmota/const.py
+++ b/homeassistant/components/tasmota/const.py
@@ -13,6 +13,7 @@ DOMAIN = "tasmota"
PLATFORMS = [
Platform.BINARY_SENSOR,
+ Platform.CAMERA,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json
index 2e0d8af2338..6c2d7ee271b 100644
--- a/homeassistant/components/tasmota/manifest.json
+++ b/homeassistant/components/tasmota/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
- "requirements": ["HATasmota==0.10.0"]
+ "requirements": ["HATasmota==0.10.1"]
}
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index 50c721e5f37..91bbc088744 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -43,6 +43,7 @@ from .const import (
ATTR_AUTHENTICATION,
ATTR_CALLBACK_QUERY_ID,
ATTR_CAPTION,
+ ATTR_CHAT_ACTION,
ATTR_CHAT_ID,
ATTR_DISABLE_NOTIF,
ATTR_DISABLE_WEB_PREV,
@@ -71,6 +72,17 @@ from .const import (
ATTR_URL,
ATTR_USERNAME,
ATTR_VERIFY_SSL,
+ CHAT_ACTION_CHOOSE_STICKER,
+ CHAT_ACTION_FIND_LOCATION,
+ CHAT_ACTION_RECORD_VIDEO,
+ CHAT_ACTION_RECORD_VIDEO_NOTE,
+ CHAT_ACTION_RECORD_VOICE,
+ CHAT_ACTION_TYPING,
+ CHAT_ACTION_UPLOAD_DOCUMENT,
+ CHAT_ACTION_UPLOAD_PHOTO,
+ CHAT_ACTION_UPLOAD_VIDEO,
+ CHAT_ACTION_UPLOAD_VIDEO_NOTE,
+ CHAT_ACTION_UPLOAD_VOICE,
CONF_ALLOWED_CHAT_IDS,
CONF_BOT_COUNT,
CONF_CONFIG_ENTRY_ID,
@@ -89,6 +101,7 @@ from .const import (
SERVICE_EDIT_REPLYMARKUP,
SERVICE_LEAVE_CHAT,
SERVICE_SEND_ANIMATION,
+ SERVICE_SEND_CHAT_ACTION,
SERVICE_SEND_DOCUMENT,
SERVICE_SEND_LOCATION,
SERVICE_SEND_MESSAGE,
@@ -153,6 +166,26 @@ SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend(
{vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_TITLE): cv.string}
)
+SERVICE_SCHEMA_SEND_CHAT_ACTION = BASE_SERVICE_SCHEMA.extend(
+ {
+ vol.Required(ATTR_CHAT_ACTION): vol.In(
+ (
+ CHAT_ACTION_TYPING,
+ CHAT_ACTION_UPLOAD_PHOTO,
+ CHAT_ACTION_RECORD_VIDEO,
+ CHAT_ACTION_UPLOAD_VIDEO,
+ CHAT_ACTION_RECORD_VOICE,
+ CHAT_ACTION_UPLOAD_VOICE,
+ CHAT_ACTION_UPLOAD_DOCUMENT,
+ CHAT_ACTION_CHOOSE_STICKER,
+ CHAT_ACTION_FIND_LOCATION,
+ CHAT_ACTION_RECORD_VIDEO_NOTE,
+ CHAT_ACTION_UPLOAD_VIDEO_NOTE,
+ )
+ ),
+ }
+)
+
SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend(
{
vol.Optional(ATTR_URL): cv.string,
@@ -268,6 +301,7 @@ SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema(
SERVICE_MAP = {
SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE,
+ SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION,
SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE,
SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_STICKER,
SERVICE_SEND_ANIMATION: SERVICE_SCHEMA_SEND_FILE,
@@ -367,6 +401,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
messages = await notify_service.send_message(
context=service.context, **kwargs
)
+ elif msgtype == SERVICE_SEND_CHAT_ACTION:
+ messages = await notify_service.send_chat_action(
+ context=service.context, **kwargs
+ )
elif msgtype in [
SERVICE_SEND_PHOTO,
SERVICE_SEND_ANIMATION,
@@ -433,6 +471,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if service_notif in [
SERVICE_SEND_MESSAGE,
+ SERVICE_SEND_CHAT_ACTION,
SERVICE_SEND_PHOTO,
SERVICE_SEND_ANIMATION,
SERVICE_SEND_VIDEO,
diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py
index 3145badbed7..42bd493489b 100644
--- a/homeassistant/components/telegram_bot/bot.py
+++ b/homeassistant/components/telegram_bot/bot.py
@@ -617,6 +617,28 @@ class TelegramNotificationService:
context=context,
)
+ async def send_chat_action(
+ self,
+ chat_action: str = "",
+ target: Any = None,
+ context: Context | None = None,
+ **kwargs: Any,
+ ) -> dict[int, int]:
+ """Send a chat action to pre-allowed chat IDs."""
+ result = {}
+ for chat_id in self.get_target_chat_ids(target):
+ _LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id)
+ is_successful = await self._send_msg(
+ self.bot.send_chat_action,
+ "Error sending action",
+ None,
+ chat_id=chat_id,
+ action=chat_action,
+ context=context,
+ )
+ result[chat_id] = is_successful
+ return result
+
async def send_file(
self,
file_type: str,
diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py
index 0f1d5193e2c..34b8a476c78 100644
--- a/homeassistant/components/telegram_bot/const.py
+++ b/homeassistant/components/telegram_bot/const.py
@@ -32,6 +32,7 @@ ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR = "deprecated_yaml_import_issue_error"
DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4.0/22")]
+SERVICE_SEND_CHAT_ACTION = "send_chat_action"
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_SEND_PHOTO = "send_photo"
SERVICE_SEND_STICKER = "send_sticker"
@@ -59,10 +60,23 @@ PARSER_MD = "markdown"
PARSER_MD2 = "markdownv2"
PARSER_PLAIN_TEXT = "plain_text"
+ATTR_CHAT_ACTION = "chat_action"
ATTR_DATA = "data"
ATTR_MESSAGE = "message"
ATTR_TITLE = "title"
+CHAT_ACTION_TYPING = "typing"
+CHAT_ACTION_UPLOAD_PHOTO = "upload_photo"
+CHAT_ACTION_RECORD_VIDEO = "record_video"
+CHAT_ACTION_UPLOAD_VIDEO = "upload_video"
+CHAT_ACTION_RECORD_VOICE = "record_voice"
+CHAT_ACTION_UPLOAD_VOICE = "upload_voice"
+CHAT_ACTION_UPLOAD_DOCUMENT = "upload_document"
+CHAT_ACTION_CHOOSE_STICKER = "choose_sticker"
+CHAT_ACTION_FIND_LOCATION = "find_location"
+CHAT_ACTION_RECORD_VIDEO_NOTE = "record_video_note"
+CHAT_ACTION_UPLOAD_VIDEO_NOTE = "upload_video_note"
+
ATTR_ARGS = "args"
ATTR_AUTHENTICATION = "authentication"
ATTR_CALLBACK_QUERY = "callback_query"
diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json
index 3a53e2b4118..3208fdfbc3e 100644
--- a/homeassistant/components/telegram_bot/icons.json
+++ b/homeassistant/components/telegram_bot/icons.json
@@ -3,6 +3,9 @@
"send_message": {
"service": "mdi:send"
},
+ "send_chat_action": {
+ "service": "mdi:send"
+ },
"send_photo": {
"service": "mdi:camera"
},
diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml
index 0ebe7988642..e0e03921a93 100644
--- a/homeassistant/components/telegram_bot/services.yaml
+++ b/homeassistant/components/telegram_bot/services.yaml
@@ -66,6 +66,38 @@ send_message:
number:
mode: box
+send_chat_action:
+ fields:
+ config_entry_id:
+ selector:
+ config_entry:
+ integration: telegram_bot
+ chat_action:
+ selector:
+ select:
+ options:
+ - "typing"
+ - "upload_photo"
+ - "record_video"
+ - "upload_video"
+ - "record_voice"
+ - "upload_voice"
+ - "upload_document"
+ - "choose_sticker"
+ - "find_location"
+ - "record_video_note"
+ - "upload_video_note"
+ translation_key: "chat_action"
+ target:
+ example: "[12345, 67890] or 12345"
+ selector:
+ text:
+ multiple: true
+ message_thread_id:
+ selector:
+ number:
+ mode: box
+
send_photo:
fields:
config_entry_id:
diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json
index 29bf51ecd0c..759b22a3368 100644
--- a/homeassistant/components/telegram_bot/strings.json
+++ b/homeassistant/components/telegram_bot/strings.json
@@ -138,6 +138,21 @@
"digest": "Digest",
"bearer_token": "Bearer token"
}
+ },
+ "chat_action": {
+ "options": {
+ "typing": "Typing",
+ "upload_photo": "Uploading photo",
+ "record_video": "Recording video",
+ "upload_video": "Uploading video",
+ "record_voice": "Recording voice",
+ "upload_voice": "Uploading voice",
+ "upload_document": "Uploading document",
+ "choose_sticker": "Choosing sticker",
+ "find_location": "Finding location",
+ "record_video_note": "Recording video note",
+ "upload_video_note": "Uploading video note"
+ }
}
},
"services": {
@@ -199,6 +214,28 @@
}
}
},
+ "send_chat_action": {
+ "name": "Send chat action",
+ "description": "Sends a chat action.",
+ "fields": {
+ "config_entry_id": {
+ "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]",
+ "description": "The config entry representing the Telegram bot to send the chat action."
+ },
+ "chat_action": {
+ "name": "Chat action",
+ "description": "Chat action to be sent."
+ },
+ "target": {
+ "name": "Target",
+ "description": "An array of pre-authorized chat IDs to send the chat action to. If not present, first allowed chat ID is the default."
+ },
+ "message_thread_id": {
+ "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]",
+ "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]"
+ }
+ }
+ },
"send_photo": {
"name": "Send photo",
"description": "Sends a photo.",
diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py
index c3f832b0c54..5a07a2c7255 100644
--- a/homeassistant/components/template/__init__.py
+++ b/homeassistant/components/template/__init__.py
@@ -102,15 +102,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index 9bcb656e4aa..a37dd18120c 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -47,12 +47,12 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -115,7 +115,11 @@ ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema(
ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(
+ make_template_entity_common_modern_schema(
+ ALARM_CONTROL_PANEL_DOMAIN, DEFAULT_NAME
+ ).schema
+)
ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index a2c5c7d460a..941eda774c4 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -48,23 +48,25 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
-from .const import CONF_AVAILABILITY_TEMPLATE
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
+ TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY,
+ TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
- TEMPLATE_ENTITY_COMMON_SCHEMA,
- TemplateEntity,
+ make_template_entity_common_modern_attributes_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
+DEFAULT_NAME = "Template Binary Sensor"
+
CONF_DELAY_ON = "delay_on"
CONF_DELAY_OFF = "delay_off"
CONF_AUTO_OFF = "auto_off"
-CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
LEGACY_FIELDS = {
CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME,
@@ -83,7 +85,9 @@ BINARY_SENSOR_COMMON_SCHEMA = vol.Schema(
)
BINARY_SENSOR_YAML_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend(
- TEMPLATE_ENTITY_COMMON_SCHEMA.schema
+ make_template_entity_common_modern_attributes_schema(
+ BINARY_SENSOR_DOMAIN, DEFAULT_NAME
+ ).schema
)
BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend(
@@ -97,10 +101,6 @@ BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All(
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_ICON_TEMPLATE): cv.template,
vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES): vol.Schema(
- {cv.string: cv.template}
- ),
vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
@@ -108,7 +108,9 @@ BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All(
vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template),
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
- ),
+ )
+ .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema)
+ .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema),
)
diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py
index d84005ccc28..0c5c10b2e5f 100644
--- a/homeassistant/components/template/button.py
+++ b/homeassistant/components/template/button.py
@@ -25,11 +25,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PRESS, DOMAIN
from .helpers import async_setup_template_entry, async_setup_template_platform
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -41,7 +41,7 @@ BUTTON_YAML_SCHEMA = vol.Schema(
vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
}
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(BUTTON_DOMAIN, DEFAULT_NAME).schema)
BUTTON_CONFIG_ENTRY_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py
index 092dbc9e41e..bcbc9584588 100644
--- a/homeassistant/components/template/config.py
+++ b/homeassistant/components/template/config.py
@@ -26,6 +26,7 @@ from homeassistant.components.number import DOMAIN as DOMAIN_NUMBER
from homeassistant.components.select import DOMAIN as DOMAIN_SELECT
from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR
from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH
+from homeassistant.components.update import DOMAIN as DOMAIN_UPDATE
from homeassistant.components.vacuum import DOMAIN as DOMAIN_VACUUM
from homeassistant.components.weather import DOMAIN as DOMAIN_WEATHER
from homeassistant.config import async_log_schema_error, config_without_domain
@@ -63,6 +64,7 @@ from . import (
select as select_platform,
sensor as sensor_platform,
switch as switch_platform,
+ update as update_platform,
vacuum as vacuum_platform,
weather as weather_platform,
)
@@ -153,6 +155,9 @@ CONFIG_SECTION_SCHEMA = vol.All(
vol.Optional(DOMAIN_SWITCH): vol.All(
cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA]
),
+ vol.Optional(DOMAIN_UPDATE): vol.All(
+ cv.ensure_list, [update_platform.UPDATE_YAML_SCHEMA]
+ ),
vol.Optional(DOMAIN_VACUUM): vol.All(
cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA]
),
@@ -171,7 +176,15 @@ TEMPLATE_BLUEPRINT_SCHEMA = vol.All(
)
-async def _async_resolve_blueprints(
+def _merge_section_variables(config: ConfigType, section_variables: ConfigType) -> None:
+ """Merges a template entity configuration's variables with the section variables."""
+ if (variables := config.pop(CONF_VARIABLES, None)) and isinstance(variables, dict):
+ config[CONF_VARIABLES] = {**section_variables, **variables}
+ else:
+ config[CONF_VARIABLES] = section_variables
+
+
+async def _async_resolve_template_config(
hass: HomeAssistant,
config: ConfigType,
) -> TemplateConfig:
@@ -182,12 +195,11 @@ async def _async_resolve_blueprints(
with suppress(ValueError): # Invalid config
raw_config = dict(config)
+ config = _backward_compat_schema(config)
if is_blueprint_instance_config(config):
blueprints = async_get_blueprints(hass)
- blueprint_inputs = await blueprints.async_inputs_from_config(
- _backward_compat_schema(config)
- )
+ blueprint_inputs = await blueprints.async_inputs_from_config(config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
config = blueprint_inputs.async_substitute()
@@ -200,14 +212,32 @@ async def _async_resolve_blueprints(
for prop in (CONF_NAME, CONF_UNIQUE_ID):
if prop in config:
config[platform][prop] = config.pop(prop)
- # For regular template entities, CONF_VARIABLES should be removed because they just
- # house input results for template entities. For Trigger based template entities
- # CONF_VARIABLES should not be removed because the variables are always
- # executed between the trigger and action.
+ # State based template entities remove CONF_VARIABLES because they pass
+ # blueprint inputs to the template entities. Trigger based template entities
+ # retain CONF_VARIABLES because the variables are always executed between
+ # the trigger and action.
if CONF_TRIGGERS not in config and CONF_VARIABLES in config:
- config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES)
+ _merge_section_variables(config[platform], config.pop(CONF_VARIABLES))
+
raw_config = dict(config)
+ # Trigger based template entities retain CONF_VARIABLES because the variables are
+ # always executed between the trigger and action.
+ elif CONF_TRIGGERS not in config and CONF_VARIABLES in config:
+ # State based template entities have 2 layers of variables. Variables at the section level
+ # and variables at the entity level should be merged together at the entity level.
+ section_variables = config.pop(CONF_VARIABLES)
+ platform_config: list[ConfigType] | ConfigType
+ platforms = [platform for platform in PLATFORMS if platform in config]
+ for platform in platforms:
+ platform_config = config[platform]
+ if platform in PLATFORMS:
+ if isinstance(platform_config, dict):
+ platform_config = [platform_config]
+
+ for entity_config in platform_config:
+ _merge_section_variables(entity_config, section_variables)
+
template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config))
template_config.raw_blueprint_inputs = raw_blueprint_inputs
template_config.raw_config = raw_config
@@ -220,7 +250,7 @@ async def async_validate_config_section(
) -> TemplateConfig:
"""Validate an entire config section for the template integration."""
- validated_config = await _async_resolve_blueprints(hass, config)
+ validated_config = await _async_resolve_template_config(hass, config)
if CONF_TRIGGERS in validated_config:
validated_config[CONF_TRIGGERS] = await async_validate_trigger_config(
@@ -283,7 +313,7 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf
)
definitions.extend(
rewrite_legacy_to_modern_configs(
- hass, template_config[old_key], legacy_fields
+ hass, new_key, template_config[old_key], legacy_fields
)
)
template_config = TemplateConfig({**template_config, new_key: definitions})
diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py
index 745e2933c58..15ed1ed2126 100644
--- a/homeassistant/components/template/config_flow.py
+++ b/homeassistant/components/template/config_flow.py
@@ -20,6 +20,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorStateClass,
)
+from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
@@ -106,6 +107,19 @@ from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_selec
from .sensor import async_create_preview_sensor
from .switch import async_create_preview_switch
from .template_entity import TemplateEntity
+from .update import (
+ CONF_BACKUP,
+ CONF_IN_PROGRESS,
+ CONF_INSTALL,
+ CONF_INSTALLED_VERSION,
+ CONF_LATEST_VERSION,
+ CONF_RELEASE_SUMMARY,
+ CONF_RELEASE_URL,
+ CONF_SPECIFIC_VERSION,
+ CONF_TITLE,
+ CONF_UPDATE_PERCENTAGE,
+ async_create_preview_update,
+)
from .vacuum import (
CONF_FAN_SPEED,
CONF_FAN_SPEED_LIST,
@@ -335,6 +349,31 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(CONF_TURN_OFF): selector.ActionSelector(),
}
+ if domain == Platform.UPDATE:
+ schema |= {
+ vol.Optional(CONF_INSTALLED_VERSION): selector.TemplateSelector(),
+ vol.Optional(CONF_LATEST_VERSION): selector.TemplateSelector(),
+ vol.Optional(CONF_INSTALL): selector.ActionSelector(),
+ vol.Optional(CONF_IN_PROGRESS): selector.TemplateSelector(),
+ vol.Optional(CONF_RELEASE_SUMMARY): selector.TemplateSelector(),
+ vol.Optional(CONF_RELEASE_URL): selector.TemplateSelector(),
+ vol.Optional(CONF_TITLE): selector.TemplateSelector(),
+ vol.Optional(CONF_UPDATE_PERCENTAGE): selector.TemplateSelector(),
+ vol.Optional(CONF_BACKUP): selector.BooleanSelector(),
+ vol.Optional(CONF_SPECIFIC_VERSION): selector.BooleanSelector(),
+ }
+ if flow_type == "config":
+ schema |= {
+ vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=[cls.value for cls in UpdateDeviceClass],
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ translation_key="update_device_class",
+ sort=True,
+ ),
+ ),
+ }
+
if domain == Platform.VACUUM:
schema |= _SCHEMA_STATE | {
vol.Required(SERVICE_START): selector.ActionSelector(),
@@ -470,11 +509,12 @@ TEMPLATE_TYPES = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.UPDATE,
Platform.VACUUM,
]
CONFIG_FLOW = {
- "user": SchemaFlowMenuStep(TEMPLATE_TYPES),
+ "user": SchemaFlowMenuStep(TEMPLATE_TYPES, True),
Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep(
config_schema(Platform.ALARM_CONTROL_PANEL),
preview="template",
@@ -539,6 +579,11 @@ CONFIG_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.SWITCH),
),
+ Platform.UPDATE: SchemaFlowFormStep(
+ config_schema(Platform.UPDATE),
+ preview="template",
+ validate_user_input=validate_user_input(Platform.UPDATE),
+ ),
Platform.VACUUM: SchemaFlowFormStep(
config_schema(Platform.VACUUM),
preview="template",
@@ -613,6 +658,11 @@ OPTIONS_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.SWITCH),
),
+ Platform.UPDATE: SchemaFlowFormStep(
+ options_schema(Platform.UPDATE),
+ preview="template",
+ validate_user_input=validate_user_input(Platform.UPDATE),
+ ),
Platform.VACUUM: SchemaFlowFormStep(
options_schema(Platform.VACUUM),
preview="template",
@@ -635,6 +685,7 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.SELECT: async_create_preview_select,
Platform.SENSOR: async_create_preview_sensor,
Platform.SWITCH: async_create_preview_switch,
+ Platform.UPDATE: async_create_preview_update,
Platform.VACUUM: async_create_preview_vacuum,
}
@@ -644,6 +695,7 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py
index 43b5fcc255a..f5b584f4c16 100644
--- a/homeassistant/components/template/const.py
+++ b/homeassistant/components/template/const.py
@@ -1,9 +1,6 @@
"""Constants for the Template Platform Components."""
-import voluptuous as vol
-
-from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform
-from homeassistant.helpers import config_validation as cv
+from homeassistant.const import Platform
from homeassistant.helpers.typing import ConfigType
CONF_ADVANCED_OPTIONS = "advanced_options"
@@ -11,24 +8,15 @@ CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
CONF_ATTRIBUTES = "attributes"
CONF_AVAILABILITY = "availability"
CONF_AVAILABILITY_TEMPLATE = "availability_template"
+CONF_DEFAULT_ENTITY_ID = "default_entity_id"
CONF_MAX = "max"
CONF_MIN = "min"
-CONF_OBJECT_ID = "object_id"
CONF_PICTURE = "picture"
CONF_PRESS = "press"
CONF_STEP = "step"
CONF_TURN_OFF = "turn_off"
CONF_TURN_ON = "turn_on"
-TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_ICON): cv.template,
- vol.Optional(CONF_NAME): cv.template,
- vol.Optional(CONF_PICTURE): cv.template,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
-)
-
DOMAIN = "template"
PLATFORM_STORAGE_KEY = "template_platforms"
@@ -47,6 +35,7 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.UPDATE,
Platform.VACUUM,
Platform.WEATHER,
]
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 44981fcb08f..c9ef1c8a26a 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -46,13 +46,13 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -122,7 +122,9 @@ COVER_YAML_SCHEMA = vol.All(
)
.extend(COVER_COMMON_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA)
- .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema),
+ .extend(
+ make_template_entity_common_modern_schema(COVER_DOMAIN, DEFAULT_NAME).schema
+ ),
cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION),
)
diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py
index 4901a7a7be8..605e39410f6 100644
--- a/homeassistant/components/template/entity.py
+++ b/homeassistant/components/template/entity.py
@@ -2,6 +2,7 @@
from abc import abstractmethod
from collections.abc import Sequence
+import logging
from typing import Any
from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE
@@ -12,7 +13,9 @@ from homeassistant.helpers.script import Script, _VarsType
from homeassistant.helpers.template import Template, TemplateStateFromEntityId
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_OBJECT_ID
+from .const import CONF_DEFAULT_ENTITY_ID
+
+_LOGGER = logging.getLogger(__name__)
class AbstractTemplateEntity(Entity):
@@ -49,7 +52,8 @@ class AbstractTemplateEntity(Entity):
optimistic is None and assumed_optimistic
)
- if (object_id := config.get(CONF_OBJECT_ID)) is not None:
+ if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
+ _, _, object_id = default_entity_id.partition(".")
self.entity_id = async_generate_entity_id(
self._entity_id_format, object_id, hass=self.hass
)
diff --git a/homeassistant/components/template/event.py b/homeassistant/components/template/event.py
index 358fec6a00f..3be117b56ed 100644
--- a/homeassistant/components/template/event.py
+++ b/homeassistant/components/template/event.py
@@ -31,11 +31,11 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_attributes_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -56,7 +56,9 @@ EVENT_COMMON_SCHEMA = vol.Schema(
)
EVENT_YAML_SCHEMA = EVENT_COMMON_SCHEMA.extend(
- make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema
+ make_template_entity_common_modern_attributes_schema(
+ EVENT_DOMAIN, DEFAULT_NAME
+ ).schema
)
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index 9504ba45ab9..90eb39dacce 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -49,13 +49,13 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -110,7 +110,7 @@ FAN_COMMON_SCHEMA = vol.Schema(
)
FAN_YAML_SCHEMA = FAN_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend(
- make_template_entity_common_modern_schema(DEFAULT_NAME).schema
+ make_template_entity_common_modern_schema(FAN_DOMAIN, DEFAULT_NAME).schema
)
FAN_LEGACY_YAML_SCHEMA = vol.All(
diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py
index a26b7bb0df1..eec08bead1c 100644
--- a/homeassistant/components/template/helpers.py
+++ b/homeassistant/components/template/helpers.py
@@ -38,7 +38,7 @@ from .const import (
CONF_ATTRIBUTES,
CONF_AVAILABILITY,
CONF_AVAILABILITY_TEMPLATE,
- CONF_OBJECT_ID,
+ CONF_DEFAULT_ENTITY_ID,
CONF_PICTURE,
DOMAIN,
)
@@ -141,13 +141,14 @@ def rewrite_legacy_to_modern_config(
def rewrite_legacy_to_modern_configs(
hass: HomeAssistant,
+ domain: str,
entity_cfg: dict[str, dict],
extra_legacy_fields: dict[str, str],
) -> list[dict]:
"""Rewrite legacy configuration definitions to modern ones."""
entities = []
for object_id, entity_conf in entity_cfg.items():
- entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
+ entity_conf = {**entity_conf, CONF_DEFAULT_ENTITY_ID: f"{domain}.{object_id}"}
entity_conf = rewrite_legacy_to_modern_config(
hass, entity_conf, extra_legacy_fields
@@ -196,7 +197,7 @@ async def async_setup_template_platform(
if legacy_fields is not None:
if legacy_key:
configs = rewrite_legacy_to_modern_configs(
- hass, config[legacy_key], legacy_fields
+ hass, domain, config[legacy_key], legacy_fields
)
else:
configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)]
diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py
index b4513fc2447..c15218bf9fc 100644
--- a/homeassistant/components/template/image.py
+++ b/homeassistant/components/template/image.py
@@ -27,11 +27,11 @@ from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import CONF_PICTURE
from .helpers import async_setup_template_entry, async_setup_template_platform
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_attributes_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -45,7 +45,11 @@ IMAGE_YAML_SCHEMA = vol.Schema(
vol.Required(CONF_URL): cv.template,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
}
-).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema)
+).extend(
+ make_template_entity_common_modern_attributes_schema(
+ IMAGE_DOMAIN, DEFAULT_NAME
+ ).schema
+)
IMAGE_CONFIG_ENTRY_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index 538d3f3aaaf..13b688dfadc 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -59,13 +59,13 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -161,7 +161,7 @@ LIGHT_COMMON_SCHEMA = vol.Schema(
LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(LIGHT_DOMAIN, DEFAULT_NAME).schema)
LIGHT_LEGACY_YAML_SCHEMA = vol.All(
cv.deprecated(CONF_ENTITY_ID),
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 04d26521ef1..3b35b09bd84 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -41,13 +41,13 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
CONF_CODE_FORMAT_TEMPLATE = "code_format_template"
@@ -75,7 +75,7 @@ LOCK_COMMON_SCHEMA = vol.Schema(
)
LOCK_YAML_SCHEMA = LOCK_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend(
- make_template_entity_common_modern_schema(DEFAULT_NAME).schema
+ make_template_entity_common_modern_schema(LOCK_DOMAIN, DEFAULT_NAME).schema
)
PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py
index 362a7e9d5c5..30b5b567908 100644
--- a/homeassistant/components/template/number.py
+++ b/homeassistant/components/template/number.py
@@ -34,12 +34,12 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -58,11 +58,11 @@ NUMBER_COMMON_SCHEMA = vol.Schema(
vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.template,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+)
NUMBER_YAML_SCHEMA = NUMBER_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(NUMBER_DOMAIN, DEFAULT_NAME).schema)
NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
diff --git a/homeassistant/components/template/schemas.py b/homeassistant/components/template/schemas.py
new file mode 100644
index 00000000000..4dbee1b4fba
--- /dev/null
+++ b/homeassistant/components/template/schemas.py
@@ -0,0 +1,109 @@
+"""Shared schemas for config entry and YAML config items."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DEVICE_ID,
+ CONF_ENTITY_PICTURE_TEMPLATE,
+ CONF_ICON,
+ CONF_ICON_TEMPLATE,
+ CONF_NAME,
+ CONF_OPTIMISTIC,
+ CONF_UNIQUE_ID,
+ CONF_VARIABLES,
+)
+from homeassistant.helpers import config_validation as cv, selector
+
+from .const import (
+ CONF_ATTRIBUTE_TEMPLATES,
+ CONF_ATTRIBUTES,
+ CONF_AVAILABILITY,
+ CONF_AVAILABILITY_TEMPLATE,
+ CONF_DEFAULT_ENTITY_ID,
+ CONF_PICTURE,
+)
+
+TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_AVAILABILITY): cv.template,
+ }
+)
+
+TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}),
+ }
+)
+
+TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME): cv.template,
+ vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
+ }
+).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
+
+
+TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = {
+ vol.Optional(CONF_OPTIMISTIC): cv.boolean,
+}
+
+TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema(
+ {
+ vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
+ {cv.string: cv.template}
+ ),
+ }
+)
+
+TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY = vol.Schema(
+ {
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ }
+)
+
+TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema(
+ {
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ }
+).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
+
+
+def make_template_entity_base_schema(domain: str, default_name: str) -> vol.Schema:
+ """Return a schema with default name."""
+ return vol.Schema(
+ {
+ vol.Optional(CONF_DEFAULT_ENTITY_ID): vol.All(
+ cv.entity_id, cv.entity_domain(domain)
+ ),
+ vol.Optional(CONF_ICON): cv.template,
+ vol.Optional(CONF_NAME, default=default_name): cv.template,
+ vol.Optional(CONF_PICTURE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ )
+
+
+def make_template_entity_common_modern_schema(
+ domain: str,
+ default_name: str,
+) -> vol.Schema:
+ """Return a schema with default name."""
+ return vol.Schema(
+ {
+ vol.Optional(CONF_AVAILABILITY): cv.template,
+ vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
+ }
+ ).extend(make_template_entity_base_schema(domain, default_name).schema)
+
+
+def make_template_entity_common_modern_attributes_schema(
+ domain: str,
+ default_name: str,
+) -> vol.Schema:
+ """Return a schema with default name."""
+ return make_template_entity_common_modern_schema(domain, default_name).extend(
+ TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema
+ )
diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py
index 8e298c28539..27aae8cb823 100644
--- a/homeassistant/components/template/select.py
+++ b/homeassistant/components/template/select.py
@@ -32,12 +32,12 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -57,7 +57,7 @@ SELECT_COMMON_SCHEMA = vol.Schema(
SELECT_YAML_SCHEMA = SELECT_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(SELECT_DOMAIN, DEFAULT_NAME).schema)
SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py
index ff956c50c6e..6e4053aecbd 100644
--- a/homeassistant/components/template/sensor.py
+++ b/homeassistant/components/template/sensor.py
@@ -52,19 +52,22 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
-from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
+ TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY,
+ TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
- TEMPLATE_ENTITY_COMMON_SCHEMA,
- TemplateEntity,
+ make_template_entity_common_modern_attributes_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
+DEFAULT_NAME = "Template Sensor"
+
LEGACY_FIELDS = {
CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME,
CONF_VALUE_TEMPLATE: CONF_STATE,
@@ -100,7 +103,11 @@ SENSOR_YAML_SCHEMA = vol.All(
}
)
.extend(SENSOR_COMMON_SCHEMA.schema)
- .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema),
+ .extend(
+ make_template_entity_common_modern_attributes_schema(
+ SENSOR_DOMAIN, DEFAULT_NAME
+ ).schema
+ ),
validate_last_reset,
)
@@ -116,17 +123,15 @@ SENSOR_LEGACY_YAML_SCHEMA = vol.All(
vol.Optional(CONF_ICON_TEMPLATE): cv.template,
vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
- {cv.string: cv.template}
- ),
vol.Optional(CONF_FRIENDLY_NAME): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
- ),
+ )
+ .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema)
+ .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema),
)
diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json
index 6de26d885cb..6ac73d43870 100644
--- a/homeassistant/components/template/strings.json
+++ b/homeassistant/components/template/strings.json
@@ -378,22 +378,23 @@
"title": "Template sensor"
},
"user": {
- "description": "This helper allows you to create helper entities that define their state using a template.",
+ "description": "This helper allows you to create helper entities that define their state using a template. What kind of template would you like to create?",
"menu_options": {
- "alarm_control_panel": "Template an alarm control panel",
- "binary_sensor": "Template a binary sensor",
- "button": "Template a button",
- "cover": "Template a cover",
- "event": "Template an event",
- "fan": "Template a fan",
- "image": "Template an image",
- "light": "Template a light",
- "lock": "Template a lock",
- "number": "Template a number",
- "select": "Template a select",
- "sensor": "Template a sensor",
- "switch": "Template a switch",
- "vacuum": "Template a vacuum"
+ "alarm_control_panel": "[%key:component::alarm_control_panel::title%]",
+ "binary_sensor": "[%key:component::binary_sensor::title%]",
+ "button": "[%key:component::button::title%]",
+ "cover": "[%key:component::cover::title%]",
+ "event": "[%key:component::event::title%]",
+ "fan": "[%key:component::fan::title%]",
+ "image": "[%key:component::image::title%]",
+ "light": "[%key:component::light::title%]",
+ "lock": "[%key:component::lock::title%]",
+ "number": "[%key:component::number::title%]",
+ "select": "[%key:component::select::title%]",
+ "sensor": "[%key:component::sensor::title%]",
+ "switch": "[%key:component::switch::title%]",
+ "update": "[%key:component::update::title%]",
+ "vacuum": "[%key:component::vacuum::title%]"
},
"title": "Template helper"
},
@@ -424,6 +425,48 @@
},
"title": "Template switch"
},
+ "update": {
+ "data": {
+ "device_id": "[%key:common::config_flow::data::device%]",
+ "device_class": "[%key:component::template::common::device_class%]",
+ "name": "[%key:common::config_flow::data::name%]",
+ "installed_version": "[%key:component::update::entity_component::_::state_attributes::installed_version::name%]",
+ "latest_version": "[%key:component::update::entity_component::_::state_attributes::latest_version::name%]",
+ "install": "Actions on install",
+ "in_progress": "[%key:component::update::entity_component::_::state_attributes::in_progress::name%]",
+ "release_summary": "[%key:component::update::entity_component::_::state_attributes::release_summary::name%]",
+ "release_url": "[%key:component::update::entity_component::_::state_attributes::release_url::name%]",
+ "title": "[%key:component::update::entity_component::_::state_attributes::title::name%]",
+ "backup": "Backup",
+ "specific_version": "Specific version",
+ "update_percentage": "Update percentage"
+ },
+ "data_description": {
+ "device_id": "[%key:component::template::common::device_id_description%]",
+ "installed_version": "Defines a template to get the installed version.",
+ "latest_version": "Defines a template to get the latest version.",
+ "install": "Defines actions to run when the update is installed. Receives variables `specific_version` and `backup` when enabled.",
+ "in_progress": "Defines a template to get the in-progress state.",
+ "release_summary": "Defines a template to get the release summary.",
+ "release_url": "Defines a template to get the release URL.",
+ "title": "Defines a template to get the update title.",
+ "backup": "Enable or disable the `automatic backup before update` option in the update repair. When disabled, the `backup` variable will always provide `False` during the `install` action and it will not accept the `backup` option.",
+ "specific_version": "Enable or disable using the `version` variable with the `install` action. When disabled, the `specific_version` variable will always provide `None` in the `install` actions",
+ "update_percentage": "Defines a template to get the update completion percentage."
+ },
+ "sections": {
+ "advanced_options": {
+ "name": "[%key:component::template::common::advanced_options%]",
+ "data": {
+ "availability": "[%key:component::template::common::availability%]"
+ },
+ "data_description": {
+ "availability": "[%key:component::template::common::availability_description%]"
+ }
+ }
+ },
+ "title": "Template update"
+ },
"vacuum": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@@ -597,7 +640,7 @@
"device_id": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]",
"event_type": "[%key:component::template::config::step::event::data::event_type%]",
- "event_types": "[%component::event::entity_component::_::state_attributes::event_types::name%]"
+ "event_types": "[%key:component::event::entity_component::_::state_attributes::event_types::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
@@ -853,6 +896,48 @@
},
"title": "[%key:component::template::config::step::switch::title%]"
},
+ "update": {
+ "data": {
+ "device_id": "[%key:common::config_flow::data::device%]",
+ "device_class": "[%key:component::template::common::device_class%]",
+ "name": "[%key:common::config_flow::data::name%]",
+ "installed_version": "[%key:component::update::entity_component::_::state_attributes::installed_version::name%]",
+ "latest_version": "[%key:component::update::entity_component::_::state_attributes::latest_version::name%]",
+ "install": "[%key:component::template::config::step::update::data::install%]",
+ "in_progress": "[%key:component::update::entity_component::_::state_attributes::in_progress::name%]",
+ "release_summary": "[%key:component::update::entity_component::_::state_attributes::release_summary::name%]",
+ "release_url": "[%key:component::update::entity_component::_::state_attributes::release_url::name%]",
+ "title": "[%key:component::update::entity_component::_::state_attributes::title::name%]",
+ "backup": "[%key:component::template::config::step::update::data::backup%]",
+ "specific_version": "[%key:component::template::config::step::update::data::specific_version%]",
+ "update_percentage": "[%key:component::template::config::step::update::data::update_percentage%]"
+ },
+ "data_description": {
+ "device_id": "[%key:component::template::common::device_id_description%]",
+ "installed_version": "[%key:component::template::config::step::update::data_description::installed_version%]",
+ "latest_version": "[%key:component::template::config::step::update::data_description::latest_version%]",
+ "install": "[%key:component::template::config::step::update::data_description::install%]",
+ "in_progress": "[%key:component::template::config::step::update::data_description::in_progress%]",
+ "release_summary": "[%key:component::template::config::step::update::data_description::release_summary%]",
+ "release_url": "[%key:component::template::config::step::update::data_description::release_url%]",
+ "title": "[%key:component::template::config::step::update::data_description::title%]",
+ "backup": "[%key:component::template::config::step::update::data_description::backup%]",
+ "specific_version": "[%key:component::template::config::step::update::data_description::specific_version%]",
+ "update_percentage": "[%key:component::template::config::step::update::data_description::update_percentage%]"
+ },
+ "sections": {
+ "advanced_options": {
+ "name": "[%key:component::template::common::advanced_options%]",
+ "data": {
+ "availability": "[%key:component::template::common::availability%]"
+ },
+ "data_description": {
+ "availability": "[%key:component::template::common::availability_description%]"
+ }
+ }
+ },
+ "title": "Template update"
+ },
"vacuum": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@@ -998,6 +1083,7 @@
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
@@ -1037,6 +1123,11 @@
"options": {
"none": "No unit of measurement"
}
+ },
+ "update_device_class": {
+ "options": {
+ "firmware": "[%key:component::update::entity_component::firmware::name%]"
+ }
}
},
"services": {
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
index cc0fd4c7ad2..2bf910ade80 100644
--- a/homeassistant/components/template/switch.py
+++ b/homeassistant/components/template/switch.py
@@ -44,13 +44,13 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
@@ -71,7 +71,7 @@ SWITCH_COMMON_SCHEMA = vol.Schema(
SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(SWITCH_DOMAIN, DEFAULT_NAME).schema)
SWITCH_LEGACY_YAML_SCHEMA = vol.All(
cv.deprecated(ATTR_ENTITY_ID),
diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py
index 3ba89cae1f4..f4e1257e36b 100644
--- a/homeassistant/components/template/template_entity.py
+++ b/homeassistant/components/template/template_entity.py
@@ -12,12 +12,8 @@ import voluptuous as vol
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
- CONF_DEVICE_ID,
- CONF_ENTITY_PICTURE_TEMPLATE,
CONF_ICON,
- CONF_ICON_TEMPLATE,
CONF_NAME,
- CONF_OPTIMISTIC,
CONF_PATH,
CONF_VARIABLES,
STATE_UNKNOWN,
@@ -32,7 +28,7 @@ from homeassistant.core import (
validate_state,
)
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import config_validation as cv, selector
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
TrackTemplate,
@@ -47,107 +43,13 @@ from homeassistant.helpers.template import (
TemplateStateFromEntityId,
result_as_boolean,
)
-from homeassistant.helpers.trigger_template_entity import (
- make_template_entity_base_schema,
-)
from homeassistant.helpers.typing import ConfigType
-from .const import (
- CONF_ATTRIBUTE_TEMPLATES,
- CONF_ATTRIBUTES,
- CONF_AVAILABILITY,
- CONF_AVAILABILITY_TEMPLATE,
- CONF_PICTURE,
- TEMPLATE_ENTITY_BASE_SCHEMA,
-)
+from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE
from .entity import AbstractTemplateEntity
_LOGGER = logging.getLogger(__name__)
-TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_AVAILABILITY): cv.template,
- }
-)
-
-TEMPLATE_ENTITY_ICON_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_ICON): cv.template,
- }
-)
-
-TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}),
- }
-)
-
-TEMPLATE_ENTITY_COMMON_SCHEMA = (
- vol.Schema(
- {
- vol.Optional(CONF_AVAILABILITY): cv.template,
- vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
- }
- )
- .extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema)
- .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema)
-)
-
-TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_NAME): cv.template,
- vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
- }
-).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
-
-
-TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = {
- vol.Optional(CONF_OPTIMISTIC): cv.boolean,
-}
-
-
-def make_template_entity_common_modern_schema(
- default_name: str,
-) -> vol.Schema:
- """Return a schema with default name."""
- return vol.Schema(
- {
- vol.Optional(CONF_AVAILABILITY): cv.template,
- vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
- }
- ).extend(make_template_entity_base_schema(default_name).schema)
-
-
-def make_template_entity_common_modern_attributes_schema(
- default_name: str,
-) -> vol.Schema:
- """Return a schema with default name."""
- return make_template_entity_common_modern_schema(default_name).extend(
- TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema
- )
-
-
-TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema(
- {
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
- {cv.string: cv.template}
- ),
- }
-)
-
-TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY = vol.Schema(
- {
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- }
-)
-
-TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema(
- {
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- }
-).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
-
class _TemplateAttribute:
"""Attribute value linked to template result."""
diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py
index 66c57eb2aab..e75d62352b5 100644
--- a/homeassistant/components/template/trigger_entity.py
+++ b/homeassistant/components/template/trigger_entity.py
@@ -4,8 +4,9 @@ from __future__ import annotations
from typing import Any
-from homeassistant.const import CONF_STATE
+from homeassistant.const import CONF_STATE, CONF_VARIABLES
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.template import _SENTINEL
from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,6 +33,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
TriggerBaseEntity.__init__(self, hass, config)
AbstractTemplateEntity.__init__(self, hass, config)
+ self._entity_variables: ScriptVariables | None = config.get(CONF_VARIABLES)
+ self._rendered_entity_variables: dict | None = None
self._state_render_error = False
async def async_added_to_hass(self) -> None:
@@ -63,9 +66,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
@callback
def _render_script_variables(self) -> dict:
"""Render configured variables."""
- if self.coordinator.data is None:
- return {}
- return self.coordinator.data["run_variables"] or {}
+ return self._rendered_entity_variables or {}
def _render_templates(self, variables: dict[str, Any]) -> None:
"""Render templates."""
@@ -92,7 +93,18 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
def _process_data(self) -> None:
"""Process new data."""
- variables = self._template_variables(self.coordinator.data["run_variables"])
+ coordinator_variables = self.coordinator.data["run_variables"]
+ if self._entity_variables:
+ entity_variables = self._entity_variables.async_simple_render(
+ coordinator_variables
+ )
+ self._rendered_entity_variables = {
+ **coordinator_variables,
+ **entity_variables,
+ }
+ else:
+ self._rendered_entity_variables = coordinator_variables
+ variables = self._template_variables(self._rendered_entity_variables)
if self._render_availability_template(variables):
self._render_templates(variables)
diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py
new file mode 100644
index 00000000000..e40aee1cf0b
--- /dev/null
+++ b/homeassistant/components/template/update.py
@@ -0,0 +1,463 @@
+"""Support for updates which integrates with other components."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+import voluptuous as vol
+
+from homeassistant.components.update import (
+ ATTR_INSTALLED_VERSION,
+ ATTR_LATEST_VERSION,
+ DEVICE_CLASSES_SCHEMA,
+ DOMAIN as UPDATE_DOMAIN,
+ ENTITY_ID_FORMAT,
+ UpdateEntity,
+ UpdateEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_DEVICE_CLASS,
+ CONF_NAME,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv, template
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
+from homeassistant.helpers.template import _SENTINEL
+from homeassistant.helpers.trigger_template_entity import CONF_PICTURE
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+
+from . import TriggerUpdateCoordinator
+from .const import DOMAIN
+from .entity import AbstractTemplateEntity
+from .helpers import (
+ async_setup_template_entry,
+ async_setup_template_platform,
+ async_setup_template_preview,
+)
+from .schemas import (
+ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
+ make_template_entity_common_modern_schema,
+)
+from .template_entity import TemplateEntity
+from .trigger_entity import TriggerEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "Template Update"
+
+ATTR_BACKUP = "backup"
+ATTR_SPECIFIC_VERSION = "specific_version"
+
+CONF_BACKUP = "backup"
+CONF_IN_PROGRESS = "in_progress"
+CONF_INSTALL = "install"
+CONF_INSTALLED_VERSION = "installed_version"
+CONF_LATEST_VERSION = "latest_version"
+CONF_RELEASE_SUMMARY = "release_summary"
+CONF_RELEASE_URL = "release_url"
+CONF_SPECIFIC_VERSION = "specific_version"
+CONF_TITLE = "title"
+CONF_UPDATE_PERCENTAGE = "update_percentage"
+
+UPDATE_COMMON_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_BACKUP, default=False): cv.boolean,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_IN_PROGRESS): cv.template,
+ vol.Optional(CONF_INSTALL): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_INSTALLED_VERSION): cv.template,
+ vol.Required(CONF_LATEST_VERSION): cv.template,
+ vol.Optional(CONF_RELEASE_SUMMARY): cv.template,
+ vol.Optional(CONF_RELEASE_URL): cv.template,
+ vol.Optional(CONF_SPECIFIC_VERSION, default=False): cv.boolean,
+ vol.Optional(CONF_TITLE): cv.template,
+ vol.Optional(CONF_UPDATE_PERCENTAGE): cv.template,
+ }
+)
+
+UPDATE_YAML_SCHEMA = UPDATE_COMMON_SCHEMA.extend(
+ make_template_entity_common_modern_schema(UPDATE_DOMAIN, DEFAULT_NAME).schema
+)
+
+UPDATE_CONFIG_ENTRY_SCHEMA = UPDATE_COMMON_SCHEMA.extend(
+ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
+)
+
+
+async def async_setup_platform(
+ hass: HomeAssistant,
+ config: ConfigType,
+ async_add_entities: AddEntitiesCallback,
+ discovery_info: DiscoveryInfoType | None = None,
+) -> None:
+ """Set up the Template update."""
+ await async_setup_template_platform(
+ hass,
+ UPDATE_DOMAIN,
+ config,
+ StateUpdateEntity,
+ TriggerUpdateEntity,
+ async_add_entities,
+ discovery_info,
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Initialize config entry."""
+ await async_setup_template_entry(
+ hass,
+ config_entry,
+ async_add_entities,
+ StateUpdateEntity,
+ UPDATE_CONFIG_ENTRY_SCHEMA,
+ )
+
+
+@callback
+def async_create_preview_update(
+ hass: HomeAssistant, name: str, config: dict[str, Any]
+) -> StateUpdateEntity:
+ """Create a preview."""
+ return async_setup_template_preview(
+ hass,
+ name,
+ config,
+ StateUpdateEntity,
+ UPDATE_CONFIG_ENTRY_SCHEMA,
+ )
+
+
+class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity):
+ """Representation of a template update features."""
+
+ _entity_id_format = ENTITY_ID_FORMAT
+
+ # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
+ # This ensures that the __init__ on AbstractTemplateEntity is not called twice.
+ def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
+ """Initialize the features."""
+
+ self._installed_version_template = config[CONF_INSTALLED_VERSION]
+ self._latest_version_template = config[CONF_LATEST_VERSION]
+
+ self._attr_device_class = config.get(CONF_DEVICE_CLASS)
+
+ self._in_progress_template = config.get(CONF_IN_PROGRESS)
+ self._release_summary_template = config.get(CONF_RELEASE_SUMMARY)
+ self._release_url_template = config.get(CONF_RELEASE_URL)
+ self._title_template = config.get(CONF_TITLE)
+ self._update_percentage_template = config.get(CONF_UPDATE_PERCENTAGE)
+
+ self._attr_supported_features = UpdateEntityFeature(0)
+ if config[CONF_BACKUP]:
+ self._attr_supported_features |= UpdateEntityFeature.BACKUP
+ if config[CONF_SPECIFIC_VERSION]:
+ self._attr_supported_features |= UpdateEntityFeature.SPECIFIC_VERSION
+ if (
+ self._in_progress_template is not None
+ or self._update_percentage_template is not None
+ ):
+ self._attr_supported_features |= UpdateEntityFeature.PROGRESS
+
+ self._optimistic_in_process = (
+ self._in_progress_template is None
+ and self._update_percentage_template is not None
+ )
+
+ @callback
+ def _update_installed_version(self, result: Any) -> None:
+ if result is None:
+ self._attr_installed_version = None
+ return
+
+ self._attr_installed_version = cv.string(result)
+
+ @callback
+ def _update_latest_version(self, result: Any) -> None:
+ if result is None:
+ self._attr_latest_version = None
+ return
+
+ self._attr_latest_version = cv.string(result)
+
+ @callback
+ def _update_in_process(self, result: Any) -> None:
+ try:
+ self._attr_in_progress = cv.boolean(result)
+ except vol.Invalid:
+ _LOGGER.error(
+ "Received invalid in_process value: %s for entity %s. Expected: True, False",
+ result,
+ self.entity_id,
+ )
+ self._attr_in_progress = False
+
+ @callback
+ def _update_release_summary(self, result: Any) -> None:
+ if result is None:
+ self._attr_release_summary = None
+ return
+
+ self._attr_release_summary = cv.string(result)
+
+ @callback
+ def _update_release_url(self, result: Any) -> None:
+ if result is None:
+ self._attr_release_url = None
+ return
+
+ try:
+ self._attr_release_url = cv.url(result)
+ except vol.Invalid:
+ _LOGGER.error(
+ "Received invalid release_url: %s for entity %s",
+ result,
+ self.entity_id,
+ )
+ self._attr_release_url = None
+
+ @callback
+ def _update_title(self, result: Any) -> None:
+ if result is None:
+ self._attr_title = None
+ return
+
+ self._attr_title = cv.string(result)
+
+ @callback
+ def _update_update_percentage(self, result: Any) -> None:
+ if result is None:
+ if self._optimistic_in_process:
+ self._attr_in_progress = False
+ self._attr_update_percentage = None
+ return
+
+ try:
+ percentage = vol.All(
+ vol.Coerce(float),
+ vol.Range(0, 100, min_included=True, max_included=True),
+ )(result)
+ if self._optimistic_in_process:
+ self._attr_in_progress = True
+ self._attr_update_percentage = percentage
+ except vol.Invalid:
+ _LOGGER.error(
+ "Received invalid update_percentage: %s for entity %s",
+ result,
+ self.entity_id,
+ )
+ self._attr_update_percentage = None
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install an update."""
+ await self.async_run_script(
+ self._action_scripts[CONF_INSTALL],
+ run_variables={ATTR_SPECIFIC_VERSION: version, ATTR_BACKUP: backup},
+ context=self._context,
+ )
+
+
+class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate):
+ """Representation of a Template update."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config: ConfigType,
+ unique_id: str | None,
+ ) -> None:
+ """Initialize the Template update."""
+ TemplateEntity.__init__(self, hass, config, unique_id)
+ AbstractTemplateUpdate.__init__(self, config)
+
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
+
+ # Scripts can be an empty list, therefore we need to check for None
+ if (install_action := config.get(CONF_INSTALL)) is not None:
+ self.add_script(CONF_INSTALL, install_action, name, DOMAIN)
+ self._attr_supported_features |= UpdateEntityFeature.INSTALL
+
+ @property
+ def entity_picture(self) -> str | None:
+ """Return the entity picture to use in the frontend."""
+ # This is needed to override the base update entity functionality
+ if self._attr_entity_picture is None:
+ # The default picture for update entities would use `self.platform.platform_name` in
+ # place of `template`. This does not work when creating an entity preview because
+ # the platform does not exist for that entity, therefore this is hardcoded as `template`.
+ return "https://brands.home-assistant.io/_/template/icon.png"
+ return self._attr_entity_picture
+
+ @callback
+ def _async_setup_templates(self) -> None:
+ """Set up templates."""
+ self.add_template_attribute(
+ "_attr_installed_version",
+ self._installed_version_template,
+ None,
+ self._update_installed_version,
+ none_on_template_error=True,
+ )
+ self.add_template_attribute(
+ "_attr_latest_version",
+ self._latest_version_template,
+ None,
+ self._update_latest_version,
+ none_on_template_error=True,
+ )
+ if self._in_progress_template is not None:
+ self.add_template_attribute(
+ "_attr_in_progress",
+ self._in_progress_template,
+ None,
+ self._update_in_process,
+ none_on_template_error=True,
+ )
+ if self._release_summary_template is not None:
+ self.add_template_attribute(
+ "_attr_release_summary",
+ self._release_summary_template,
+ None,
+ self._update_release_summary,
+ none_on_template_error=True,
+ )
+ if self._release_url_template is not None:
+ self.add_template_attribute(
+ "_attr_release_url",
+ self._release_url_template,
+ None,
+ self._update_release_url,
+ none_on_template_error=True,
+ )
+ if self._title_template is not None:
+ self.add_template_attribute(
+ "_attr_title",
+ self._title_template,
+ None,
+ self._update_title,
+ none_on_template_error=True,
+ )
+ if self._update_percentage_template is not None:
+ self.add_template_attribute(
+ "_attr_update_percentage",
+ self._update_percentage_template,
+ None,
+ self._update_update_percentage,
+ none_on_template_error=True,
+ )
+ super()._async_setup_templates()
+
+
+class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate):
+ """Update entity based on trigger data."""
+
+ domain = UPDATE_DOMAIN
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ coordinator: TriggerUpdateCoordinator,
+ config: ConfigType,
+ ) -> None:
+ """Initialize the entity."""
+ TriggerEntity.__init__(self, hass, coordinator, config)
+ AbstractTemplateUpdate.__init__(self, config)
+
+ for key in (
+ CONF_INSTALLED_VERSION,
+ CONF_LATEST_VERSION,
+ ):
+ self._to_render_simple.append(key)
+ self._parse_result.add(key)
+
+ # Scripts can be an empty list, therefore we need to check for None
+ if (install_action := config.get(CONF_INSTALL)) is not None:
+ self.add_script(
+ CONF_INSTALL,
+ install_action,
+ self._rendered.get(CONF_NAME, DEFAULT_NAME),
+ DOMAIN,
+ )
+ self._attr_supported_features |= UpdateEntityFeature.INSTALL
+
+ for key in (
+ CONF_IN_PROGRESS,
+ CONF_RELEASE_SUMMARY,
+ CONF_RELEASE_URL,
+ CONF_TITLE,
+ CONF_UPDATE_PERCENTAGE,
+ ):
+ if isinstance(config.get(key), template.Template):
+ self._to_render_simple.append(key)
+ self._parse_result.add(key)
+
+ # Ensure the entity picture can resolve None to produce the default picture.
+ if CONF_PICTURE in config:
+ self._parse_result.add(CONF_PICTURE)
+
+ async def async_added_to_hass(self) -> None:
+ """Restore last state."""
+ await super().async_added_to_hass()
+ if (
+ (last_state := await self.async_get_last_state()) is not None
+ and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
+ and self._attr_installed_version is None
+ and self._attr_latest_version is None
+ ):
+ self._attr_installed_version = last_state.attributes[ATTR_INSTALLED_VERSION]
+ self._attr_latest_version = last_state.attributes[ATTR_LATEST_VERSION]
+ self.restore_attributes(last_state)
+
+ @property
+ def entity_picture(self) -> str | None:
+ """Return entity picture."""
+ if (picture := self._rendered.get(CONF_PICTURE)) is None:
+ return UpdateEntity.entity_picture.fget(self) # type: ignore[attr-defined]
+ return picture
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle update of the data."""
+ self._process_data()
+
+ if not self.available:
+ self.async_write_ha_state()
+ return
+
+ write_ha_state = False
+ for key, updater in (
+ (CONF_INSTALLED_VERSION, self._update_installed_version),
+ (CONF_LATEST_VERSION, self._update_latest_version),
+ (CONF_IN_PROGRESS, self._update_in_process),
+ (CONF_RELEASE_SUMMARY, self._update_release_summary),
+ (CONF_RELEASE_URL, self._update_release_url),
+ (CONF_TITLE, self._update_title),
+ (CONF_UPDATE_PERCENTAGE, self._update_update_percentage),
+ ):
+ if (rendered := self._rendered.get(key, _SENTINEL)) is not _SENTINEL:
+ updater(rendered)
+ write_ha_state = True
+
+ if len(self._rendered) > 0:
+ # In case any non optimistic template
+ write_ha_state = True
+
+ if write_ha_state:
+ self.async_write_ha_state()
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index 242a534187a..87211a22414 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -54,14 +54,14 @@ from .helpers import (
async_setup_template_platform,
async_setup_template_preview,
)
-from .template_entity import (
+from .schemas import (
TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
- TemplateEntity,
make_template_entity_common_modern_attributes_schema,
)
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -109,7 +109,11 @@ VACUUM_COMMON_SCHEMA = vol.Schema(
VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
-).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema)
+).extend(
+ make_template_entity_common_modern_attributes_schema(
+ VACUUM_DOMAIN, DEFAULT_NAME
+ ).schema
+)
VACUUM_LEGACY_YAML_SCHEMA = vol.All(
cv.deprecated(CONF_ENTITY_ID),
diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py
index bddb55197c3..8a23a4f132f 100644
--- a/homeassistant/components/template/weather.py
+++ b/homeassistant/components/template/weather.py
@@ -53,7 +53,8 @@ from homeassistant.util.unit_conversion import (
from .coordinator import TriggerUpdateCoordinator
from .helpers import async_setup_template_platform
-from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
+from .schemas import make_template_entity_common_modern_schema
+from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
CHECK_FORECAST_KEYS = (
@@ -132,7 +133,7 @@ WEATHER_YAML_SCHEMA = vol.Schema(
vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS),
}
-).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
+).extend(make_template_entity_common_modern_schema(WEATHER_DOMAIN, DEFAULT_NAME).schema)
PLATFORM_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index 2642bd2f7d5..8cf5f8b2b58 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -179,7 +179,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
)
await live_coordinator.async_config_entry_first_refresh()
- await history_coordinator.async_config_entry_first_refresh()
await info_coordinator.async_config_entry_first_refresh()
# Create energy site model
diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json
index a5a6cc18411..05e4d2b85ff 100644
--- a/homeassistant/components/tesla_fleet/strings.json
+++ b/homeassistant/components/tesla_fleet/strings.json
@@ -442,7 +442,7 @@
"name": "Odometer"
},
"island_status": {
- "name": "Grid Status",
+ "name": "Grid status",
"state": {
"island_status_unknown": "Unknown",
"on_grid": "[%key:common::state::connected%]",
@@ -452,7 +452,7 @@
}
},
"storm_mode_active": {
- "name": "Storm Watch active"
+ "name": "Storm watch active"
},
"vehicle_state_tpms_pressure_fl": {
"name": "Tire pressure front left"
diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json
index f50f5a75f70..46b63fc2c73 100644
--- a/homeassistant/components/teslemetry/icons.json
+++ b/homeassistant/components/teslemetry/icons.json
@@ -752,6 +752,9 @@
},
"vehicle_state_valet_mode": {
"default": "mdi:speedometer-slow"
+ },
+ "guest_mode_enabled": {
+ "default": "mdi:account-group"
}
}
},
diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py
index 7a6a7b55c0c..4fecd98cf24 100644
--- a/homeassistant/components/teslemetry/services.py
+++ b/homeassistant/components/teslemetry/services.py
@@ -152,7 +152,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
time: int | None = None
# Convert time to minutes since minute
if "time" in call.data:
- (hours, minutes, *seconds) = call.data["time"].split(":")
+ (hours, minutes, *_seconds) = call.data["time"].split(":")
time = int(hours) * 60 + int(minutes)
elif call.data["enable"]:
raise ServiceValidationError(
@@ -191,7 +191,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
)
departure_time: int | None = None
if ATTR_DEPARTURE_TIME in call.data:
- (hours, minutes, *seconds) = call.data[ATTR_DEPARTURE_TIME].split(":")
+ (hours, minutes, *_seconds) = call.data[ATTR_DEPARTURE_TIME].split(":")
departure_time = int(hours) * 60 + int(minutes)
elif preconditioning_enabled:
raise ServiceValidationError(
@@ -207,7 +207,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
end_off_peak_time: int | None = None
if ATTR_END_OFF_PEAK_TIME in call.data:
- (hours, minutes, *seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":")
+ (hours, minutes, *_seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":")
end_off_peak_time = int(hours) * 60 + int(minutes)
elif off_peak_charging_enabled:
raise ServiceValidationError(
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index 510e2b45a02..b78f2d00f60 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -1084,6 +1084,9 @@
},
"vehicle_state_valet_mode": {
"name": "Valet mode"
+ },
+ "guest_mode_enabled": {
+ "name": "Guest mode"
}
},
"update": {
diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py
index aae973cf315..c0ad058ee2c 100644
--- a/homeassistant/components/teslemetry/switch.py
+++ b/homeassistant/components/teslemetry/switch.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
-from itertools import chain
from typing import Any
from tesla_fleet_api.const import AutoSeat, Scope
@@ -38,6 +37,7 @@ PARALLEL_UPDATES = 0
class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
"""Describes Teslemetry Switch entity."""
+ polling: bool = False
on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
scopes: list[Scope]
@@ -53,6 +53,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
TeslemetrySwitchEntityDescription(
key="vehicle_state_sentry_mode",
+ polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode(
lambda value: callback(None if value is None else value != "Off")
),
@@ -62,6 +63,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="vehicle_state_valet_mode",
+ polling=True,
streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled(
value
),
@@ -72,6 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_left",
+ polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft(
callback
),
@@ -85,6 +88,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_right",
+ polling=True,
streaming_listener=lambda vehicle,
callback: vehicle.listen_AutoSeatClimateRight(callback),
on_func=lambda api: api.remote_auto_seat_climate_request(
@@ -97,6 +101,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_steering_wheel_heat",
+ polling=True,
streaming_listener=lambda vehicle,
callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback),
on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request(
@@ -109,6 +114,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_defrost_mode",
+ polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode(
lambda value: callback(None if value is None else value != "Off")
),
@@ -120,6 +126,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="charge_state_charging_state",
+ polling=True,
unique_id="charge_state_user_charge_enable_request",
value_func=lambda state: state in {"Starting", "Charging"},
streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState(
@@ -131,6 +138,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
off_func=lambda api: api.charge_stop(),
scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS],
),
+ TeslemetrySwitchEntityDescription(
+ key="guest_mode_enabled",
+ polling=False,
+ unique_id="guest_mode_enabled",
+ streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled(
+ callback
+ ),
+ on_func=lambda api: api.guest_mode(True),
+ off_func=lambda api: api.guest_mode(False),
+ scopes=[Scope.VEHICLE_CMDS],
+ ),
)
@@ -141,35 +159,40 @@ async def async_setup_entry(
) -> None:
"""Set up the Teslemetry Switch platform from a config entry."""
- async_add_entities(
- chain(
- (
- TeslemetryVehiclePollingVehicleSwitchEntity(
- vehicle, description, entry.runtime_data.scopes
+ entities: list[SwitchEntity] = []
+
+ for vehicle in entry.runtime_data.vehicles:
+ for description in VEHICLE_DESCRIPTIONS:
+ if vehicle.poll or vehicle.firmware < description.streaming_firmware:
+ if description.polling:
+ entities.append(
+ TeslemetryVehiclePollingVehicleSwitchEntity(
+ vehicle, description, entry.runtime_data.scopes
+ )
+ )
+ else:
+ entities.append(
+ TeslemetryStreamingVehicleSwitchEntity(
+ vehicle, description, entry.runtime_data.scopes
+ )
)
- if vehicle.poll or vehicle.firmware < description.streaming_firmware
- else TeslemetryStreamingVehicleSwitchEntity(
- vehicle, description, entry.runtime_data.scopes
- )
- for vehicle in entry.runtime_data.vehicles
- for description in VEHICLE_DESCRIPTIONS
- ),
- (
- TeslemetryChargeFromGridSwitchEntity(
- energysite,
- entry.runtime_data.scopes,
- )
- for energysite in entry.runtime_data.energysites
- if energysite.info_coordinator.data.get("components_battery")
- and energysite.info_coordinator.data.get("components_solar")
- ),
- (
- TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes)
- for energysite in entry.runtime_data.energysites
- if energysite.info_coordinator.data.get("components_storm_mode_capable")
- ),
+
+ entities.extend(
+ TeslemetryChargeFromGridSwitchEntity(
+ energysite,
+ entry.runtime_data.scopes,
)
+ for energysite in entry.runtime_data.energysites
+ if energysite.info_coordinator.data.get("components_battery")
+ and energysite.info_coordinator.data.get("components_solar")
)
+ entities.extend(
+ TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes)
+ for energysite in entry.runtime_data.energysites
+ if energysite.info_coordinator.data.get("components_storm_mode_capable")
+ )
+
+ async_add_entities(entities)
class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity):
diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py
index bf202a50c34..42caf5d9e32 100644
--- a/homeassistant/components/thread/config_flow.py
+++ b/homeassistant/components/thread/config_flow.py
@@ -5,7 +5,11 @@ from __future__ import annotations
from typing import Any
from homeassistant.components import onboarding
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ DEFAULT_DISCOVERY_UNIQUE_ID,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -18,14 +22,18 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_data: None) -> ConfigFlowResult:
"""Set up by import from async_setup."""
- await self._async_handle_discovery_without_unique_id()
+ await self.async_set_unique_id(
+ DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False
+ )
return self.async_create_entry(title="Thread", data={})
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Set up by import from async_setup."""
- await self._async_handle_discovery_without_unique_id()
+ await self.async_set_unique_id(
+ DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False
+ )
return self.async_create_entry(title="Thread", data={})
async def async_step_zeroconf(
diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py
index 4bd4c6e81f7..20289ff1394 100644
--- a/homeassistant/components/thread/discovery.py
+++ b/homeassistant/components/thread/discovery.py
@@ -28,6 +28,7 @@ KNOWN_BRANDS: dict[str | None, str] = {
"Apple Inc.": "apple",
"Aqara": "aqara_gateway",
"eero": "eero",
+ "GL.iNET Inc.": "glinet",
"Google Inc.": "google",
"HomeAssistant": "homeassistant",
"Home Assistant": "homeassistant",
diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json
index 868ced022b8..22d55f57d48 100644
--- a/homeassistant/components/thread/manifest.json
+++ b/homeassistant/components/thread/manifest.json
@@ -8,5 +8,6 @@
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"],
+ "single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}
diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py
index 56d51f4f1e0..bb57170904f 100644
--- a/homeassistant/components/threshold/__init__.py
+++ b/homeassistant/components/threshold/__init__.py
@@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -50,8 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, (Platform.BINARY_SENSOR,)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
-
return True
@@ -89,12 +88,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
-
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py
index 29f4a0986c1..93468e89b46 100644
--- a/homeassistant/components/threshold/config_flow.py
+++ b/homeassistant/components/threshold/config_flow.py
@@ -84,6 +84,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index db08f422500..0844915daa4 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.31.6"]
+ "requirements": ["pyTibber==0.32.2"]
}
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index 1c56d5b2ce6..b087ef406a1 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -377,7 +377,6 @@ class TibberSensorElPrice(TibberSensor):
"app_nickname": None,
"grid_company": None,
"estimated_annual_consumption": None,
- "price_level": None,
"max_price": None,
"avg_price": None,
"min_price": None,
@@ -405,16 +404,16 @@ class TibberSensorElPrice(TibberSensor):
await self._fetch_data()
elif (
- self._tibber_home.current_price_total
+ self._tibber_home.price_total
and self._last_updated
and self._last_updated.hour == now.hour
+ and now - self._last_updated < timedelta(minutes=15)
and self._tibber_home.last_data_timestamp
):
return
res = self._tibber_home.current_price_data()
- self._attr_native_value, price_level, self._last_updated, price_rank = res
- self._attr_extra_state_attributes["price_level"] = price_level
+ self._attr_native_value, self._last_updated, price_rank = res
self._attr_extra_state_attributes["intraday_price_ranking"] = price_rank
attrs = self._tibber_home.current_attributes()
diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py
index 938e96b9917..d5bb3fd4854 100644
--- a/homeassistant/components/tibber/services.py
+++ b/homeassistant/components/tibber/services.py
@@ -50,7 +50,6 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
{
"start_time": starts_at,
"price": price,
- "level": tibber_home.price_level.get(starts_at),
}
for starts_at, price in tibber_home.price_total.items()
]
diff --git a/homeassistant/components/tod/__init__.py b/homeassistant/components/tod/__init__.py
index 4f3f365ea59..3740c6b685f 100644
--- a/homeassistant/components/tod/__init__.py
+++ b/homeassistant/components/tod/__init__.py
@@ -13,16 +13,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, (Platform.BINARY_SENSOR,)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
-
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
diff --git a/homeassistant/components/tod/config_flow.py b/homeassistant/components/tod/config_flow.py
index 0bbd5a528af..df9596f3a20 100644
--- a/homeassistant/components/tod/config_flow.py
+++ b/homeassistant/components/tod/config_flow.py
@@ -43,6 +43,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py
index d679a57bf96..a1379b003f6 100644
--- a/homeassistant/components/todo/intent.py
+++ b/homeassistant/components/todo/intent.py
@@ -65,7 +65,6 @@ class ListAddItemIntent(intent.IntentHandler):
)
response: intent.IntentResponse = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
[
intent.IntentResponseTarget(
@@ -141,7 +140,6 @@ class ListCompleteItemIntent(intent.IntentHandler):
)
response: intent.IntentResponse = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
[
intent.IntentResponseTarget(
diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py
index 696b7395f1e..f7e6568575e 100644
--- a/homeassistant/components/togrill/__init__.py
+++ b/homeassistant/components/togrill/__init__.py
@@ -8,7 +8,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator
-_PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.NUMBER]
+_PLATFORMS: list[Platform] = [
+ Platform.EVENT,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.NUMBER,
+]
async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool:
diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py
index 75964067de7..16b9871dd3e 100644
--- a/homeassistant/components/togrill/coordinator.py
+++ b/homeassistant/components/togrill/coordinator.py
@@ -2,10 +2,10 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from datetime import timedelta
import logging
-from typing import TypeVar
from bleak.exc import BleakError
from togrill_bluetooth.client import Client
@@ -32,15 +32,13 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_PROBE_COUNT
+from .const import CONF_PROBE_COUNT, DOMAIN
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
SCAN_INTERVAL = timedelta(seconds=30)
LOGGER = logging.getLogger(__name__)
-PacketType = TypeVar("PacketType", bound=Packet)
-
def get_version_string(packet: PacketA0Notify) -> str:
"""Construct a version string from packet data."""
@@ -74,13 +72,19 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
name="ToGrill",
update_interval=SCAN_INTERVAL,
)
- self.address = config_entry.data[CONF_ADDRESS]
+ self.address: str = config_entry.data[CONF_ADDRESS]
self.data = {}
- self.device_info = DeviceInfo(
- connections={(CONNECTION_BLUETOOTH, self.address)}
- )
self._packet_listeners: list[Callable[[Packet], None]] = []
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(CONNECTION_BLUETOOTH, self.address)},
+ identifiers={(DOMAIN, self.address)},
+ name=config_entry.data[CONF_MODEL],
+ model_id=config_entry.data[CONF_MODEL],
+ )
+
config_entry.async_on_unload(
async_register_callback(
hass,
@@ -90,6 +94,23 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
)
)
+ def get_device_info(self, probe_number: int | None) -> DeviceInfo:
+ """Return device info."""
+
+ if probe_number is None:
+ return DeviceInfo(
+ identifiers={(DOMAIN, self.address)},
+ )
+
+ return DeviceInfo(
+ translation_key="probe",
+ translation_placeholders={
+ "probe_number": str(probe_number),
+ },
+ identifiers={(DOMAIN, f"{self.address}_{probe_number}")},
+ via_device=(DOMAIN, self.address),
+ )
+
@callback
def async_add_packet_listener(
self, packet_callback: Callable[[Packet], None]
@@ -116,14 +137,19 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
raise DeviceNotFound("Unable to find device")
try:
- client = await Client.connect(device, self._notify_callback)
+ client = await Client.connect(
+ device,
+ self._notify_callback,
+ disconnected_callback=self._disconnected_callback,
+ )
except BleakError as exc:
self.logger.debug("Connection failed", exc_info=True)
raise DeviceNotFound("Unable to connect to device") from exc
try:
- packet_a0 = await client.read(PacketA0Notify)
- except (BleakError, DecodeError) as exc:
+ async with asyncio.timeout(10):
+ packet_a0 = await client.read(PacketA0Notify)
+ except (BleakError, DecodeError, TimeoutError) as exc:
await client.disconnect()
raise DeviceFailed(f"Device failed {exc}") from exc
@@ -132,9 +158,7 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
- connections={(CONNECTION_BLUETOOTH, self.address)},
- name=config_entry.data[CONF_MODEL],
- model_id=config_entry.data[CONF_MODEL],
+ identifiers={(DOMAIN, self.address)},
sw_version=get_version_string(packet_a0),
)
@@ -148,18 +172,15 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
self.client = None
async def _get_connected_client(self) -> Client:
- if self.client and not self.client.is_connected:
- await self.client.disconnect()
- self.client = None
if self.client:
return self.client
self.client = await self._connect_and_update_registry()
return self.client
- def get_packet(
- self, packet_type: type[PacketType], probe=None
- ) -> PacketType | None:
+ def get_packet[PacketT: Packet](
+ self, packet_type: type[PacketT], probe=None
+ ) -> PacketT | None:
"""Get a cached packet of a certain type."""
if packet := self.data.get((packet_type.type, probe)):
@@ -175,6 +196,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]:
"""Poll the device."""
+ if self.client and not self.client.is_connected:
+ await self.client.disconnect()
+ self.client = None
+ self._async_request_refresh_soon()
+ raise DeviceFailed("Device was disconnected")
+
client = await self._get_connected_client()
try:
await client.request(PacketA0Notify)
@@ -185,6 +212,27 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
raise DeviceFailed(f"Device failed {exc}") from exc
return self.data
+ @callback
+ def _async_request_refresh_soon(self) -> None:
+ """Request a refresh in the near future.
+
+ This way have been called during an update and
+ would be ignored by debounce logic, so we delay
+ it by a slight amount to hopefully let the current
+ update finish first.
+ """
+
+ async def _delayed_refresh() -> None:
+ await asyncio.sleep(0.5)
+ await self.async_request_refresh()
+
+ self.config_entry.async_create_task(self.hass, _delayed_refresh())
+
+ @callback
+ def _disconnected_callback(self) -> None:
+ """Handle Bluetooth device being disconnected."""
+ self._async_request_refresh_soon()
+
@callback
def _async_handle_bluetooth_event(
self,
@@ -192,5 +240,5 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
change: BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
- if not self.client and isinstance(self.last_exception, DeviceNotFound):
- self.hass.async_create_task(self.async_refresh())
+ if isinstance(self.last_exception, DeviceNotFound):
+ self._async_request_refresh_soon()
diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py
index 7d956ac2d57..f744b9f851b 100644
--- a/homeassistant/components/togrill/entity.py
+++ b/homeassistant/components/togrill/entity.py
@@ -19,10 +19,12 @@ class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]):
_attr_has_entity_name = True
- def __init__(self, coordinator: ToGrillCoordinator) -> None:
+ def __init__(
+ self, coordinator: ToGrillCoordinator, probe_number: int | None = None
+ ) -> None:
"""Initialize coordinator entity."""
super().__init__(coordinator)
- self._attr_device_info = coordinator.device_info
+ self._attr_device_info = coordinator.get_device_info(probe_number)
def _get_client(self) -> Client:
client = self.coordinator.client
diff --git a/homeassistant/components/togrill/event.py b/homeassistant/components/togrill/event.py
index d7d67b464d1..a598ec70a3c 100644
--- a/homeassistant/components/togrill/event.py
+++ b/homeassistant/components/togrill/event.py
@@ -34,7 +34,7 @@ class ToGrillEventEntity(ToGrillEntity, EventEntity):
def __init__(self, coordinator: ToGrillCoordinator, probe_number: int) -> None:
"""Initialize the entity."""
- super().__init__(coordinator=coordinator)
+ super().__init__(coordinator=coordinator, probe_number=probe_number)
self._attr_translation_key = "event"
self._attr_translation_placeholders = {"probe_number": f"{probe_number}"}
diff --git a/homeassistant/components/togrill/icons.json b/homeassistant/components/togrill/icons.json
new file mode 100644
index 00000000000..a379bf8d978
--- /dev/null
+++ b/homeassistant/components/togrill/icons.json
@@ -0,0 +1,21 @@
+{
+ "entity": {
+ "select": {
+ "grill_type": {
+ "default": "mdi:grill",
+ "state": {
+ "turkey": "mdi:food-turkey",
+ "sausage": "mdi:sausage",
+ "fish": "mdi:fish",
+ "hamburger": "mdi:hamburger",
+ "bbq_smoke": "mdi:smoke",
+ "hot_smoke": "mdi:smoke",
+ "cold_smoke": "mdi:smoke"
+ }
+ },
+ "taste": {
+ "default": "mdi:food-steak"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py
index a87fec8d2d3..1055aea32e3 100644
--- a/homeassistant/components/togrill/number.py
+++ b/homeassistant/components/togrill/number.py
@@ -2,14 +2,16 @@
from __future__ import annotations
-from collections.abc import Callable, Mapping
+from collections.abc import Callable, Generator, Mapping
from dataclasses import dataclass
from typing import Any
from togrill_bluetooth.packets import (
+ AlarmType,
PacketA0Notify,
PacketA6Write,
PacketA8Notify,
+ PacketA300Write,
PacketA301Write,
PacketWrite,
)
@@ -37,43 +39,95 @@ class ToGrillNumberEntityDescription(NumberEntityDescription):
"""Description of entity."""
get_value: Callable[[ToGrillCoordinator], float | None]
- set_packet: Callable[[float], PacketWrite]
+ set_packet: Callable[[ToGrillCoordinator, float], PacketWrite]
entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True
+ probe_number: int | None = None
-def _get_temperature_target_description(
+def _get_temperature_descriptions(
probe_number: int,
-) -> ToGrillNumberEntityDescription:
- def _set_packet(value: float | None) -> PacketWrite:
+) -> Generator[ToGrillNumberEntityDescription]:
+ def _get_description(
+ variant: str,
+ icon: str | None,
+ set_packet: Callable[[ToGrillCoordinator, float], PacketWrite],
+ get_value: Callable[[ToGrillCoordinator], float | None],
+ ) -> ToGrillNumberEntityDescription:
+ return ToGrillNumberEntityDescription(
+ key=f"temperature_{variant}_{probe_number}",
+ translation_key=f"temperature_{variant}",
+ translation_placeholders={"probe_number": f"{probe_number}"},
+ device_class=NumberDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ native_min_value=0,
+ native_max_value=250,
+ mode=NumberMode.BOX,
+ icon=icon,
+ set_packet=set_packet,
+ get_value=get_value,
+ entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
+ probe_number=probe_number,
+ )
+
+ def _get_temperatures(
+ coordinator: ToGrillCoordinator, alarm_type: AlarmType
+ ) -> tuple[None | float, None | float]:
+ if not (packet := coordinator.get_packet(PacketA8Notify, probe_number)):
+ return None, None
+
+ if packet.alarm_type != alarm_type:
+ return None, None
+
+ return packet.temperature_1, packet.temperature_2
+
+ def _set_target(
+ coordinator: ToGrillCoordinator, value: float | None
+ ) -> PacketWrite:
if value == 0.0:
value = None
return PacketA301Write(probe=probe_number, target=value)
- def _get_value(coordinator: ToGrillCoordinator) -> float | None:
- if packet := coordinator.get_packet(PacketA8Notify, probe_number):
- if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET:
- return packet.temperature_1
- return None
+ def _set_minimum(
+ coordinator: ToGrillCoordinator, value: float | None
+ ) -> PacketWrite:
+ _, maximum = _get_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)
+ if value == 0.0:
+ value = None
+ return PacketA300Write(probe=probe_number, minimum=value, maximum=maximum)
- return ToGrillNumberEntityDescription(
- key=f"temperature_target_{probe_number}",
- translation_key="temperature_target",
- translation_placeholders={"probe_number": f"{probe_number}"},
- device_class=NumberDeviceClass.TEMPERATURE,
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- native_min_value=0,
- native_max_value=250,
- mode=NumberMode.BOX,
- set_packet=_set_packet,
- get_value=_get_value,
- entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
+ def _set_maximum(
+ coordinator: ToGrillCoordinator, value: float | None
+ ) -> PacketWrite:
+ minimum, _ = _get_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)
+ if value == 0.0:
+ value = None
+ return PacketA300Write(probe=probe_number, minimum=minimum, maximum=value)
+
+ yield _get_description(
+ "target",
+ "mdi:thermometer-check",
+ _set_target,
+ lambda x: _get_temperatures(x, AlarmType.TEMPERATURE_TARGET)[0],
+ )
+ yield _get_description(
+ "minimum",
+ "mdi:thermometer-chevron-down",
+ _set_minimum,
+ lambda x: _get_temperatures(x, AlarmType.TEMPERATURE_RANGE)[0],
+ )
+ yield _get_description(
+ "maximum",
+ "mdi:thermometer-chevron-up",
+ _set_maximum,
+ lambda x: _get_temperatures(x, AlarmType.TEMPERATURE_RANGE)[1],
)
ENTITY_DESCRIPTIONS = (
*[
- _get_temperature_target_description(probe_number)
+ description
for probe_number in range(1, MAX_PROBE_COUNT + 1)
+ for description in _get_temperature_descriptions(probe_number)
],
ToGrillNumberEntityDescription(
key="alarm_interval",
@@ -84,7 +138,7 @@ ENTITY_DESCRIPTIONS = (
native_max_value=15,
native_step=5,
mode=NumberMode.BOX,
- set_packet=lambda x: (
+ set_packet=lambda coordinator, x: (
PacketA6Write(temperature_unit=None, alarm_interval=round(x))
),
get_value=lambda x: (
@@ -122,7 +176,7 @@ class ToGrillNumber(ToGrillEntity, NumberEntity):
) -> None:
"""Initialize."""
- super().__init__(coordinator)
+ super().__init__(coordinator, probe_number=entity_description.probe_number)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.address}_{entity_description.key}"
@@ -134,5 +188,5 @@ class ToGrillNumber(ToGrillEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set value on device."""
- packet = self.entity_description.set_packet(value)
+ packet = self.entity_description.set_packet(self.coordinator, value)
await self._write_packet(packet)
diff --git a/homeassistant/components/togrill/select.py b/homeassistant/components/togrill/select.py
new file mode 100644
index 00000000000..39644313cf2
--- /dev/null
+++ b/homeassistant/components/togrill/select.py
@@ -0,0 +1,176 @@
+"""Support for select entities."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Generator, Mapping
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, TypeVar
+
+from togrill_bluetooth.packets import (
+ GrillType,
+ PacketA8Notify,
+ PacketA303Write,
+ PacketWrite,
+ Taste,
+)
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import ToGrillConfigEntry
+from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
+from .coordinator import ToGrillCoordinator
+from .entity import ToGrillEntity
+
+PARALLEL_UPDATES = 0
+
+OPTION_NONE = "none"
+
+
+@dataclass(kw_only=True, frozen=True)
+class ToGrillSelectEntityDescription(SelectEntityDescription):
+ """Description of entity."""
+
+ get_value: Callable[[ToGrillCoordinator], str | None]
+ set_packet: Callable[[ToGrillCoordinator, str], PacketWrite]
+ entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True
+ probe_number: int | None = None
+
+
+_ENUM = TypeVar("_ENUM", bound=Enum)
+
+
+def _get_enum_from_name(type_: type[_ENUM], value: str) -> _ENUM | None:
+ """Return enum value or None."""
+ if value == OPTION_NONE:
+ return None
+ return type_[value.upper()]
+
+
+def _get_enum_from_value(type_: type[_ENUM], value: int | None) -> _ENUM | None:
+ """Return enum value or None."""
+ if value is None:
+ return None
+ try:
+ return type_(value)
+ except ValueError:
+ return None
+
+
+def _get_enum_options(type_: type[_ENUM]) -> list[str]:
+ """Return a list of enum options."""
+ values = [OPTION_NONE]
+ values.extend(option.name.lower() for option in type_)
+ return values
+
+
+def _get_probe_descriptions(
+ probe_number: int,
+) -> Generator[ToGrillSelectEntityDescription]:
+ def _get_grill_info(
+ coordinator: ToGrillCoordinator,
+ ) -> tuple[GrillType | None, Taste | None]:
+ if not (packet := coordinator.get_packet(PacketA8Notify, probe_number)):
+ return None, None
+
+ return _get_enum_from_value(GrillType, packet.grill_type), _get_enum_from_value(
+ Taste, packet.taste
+ )
+
+ def _set_grill_type(coordinator: ToGrillCoordinator, value: str) -> PacketWrite:
+ _, taste = _get_grill_info(coordinator)
+ grill_type = _get_enum_from_name(GrillType, value)
+ return PacketA303Write(probe=probe_number, grill_type=grill_type, taste=taste)
+
+ def _set_taste(coordinator: ToGrillCoordinator, value: str) -> PacketWrite:
+ grill_type, _ = _get_grill_info(coordinator)
+ taste = _get_enum_from_name(Taste, value)
+ return PacketA303Write(probe=probe_number, grill_type=grill_type, taste=taste)
+
+ def _get_grill_type(coordinator: ToGrillCoordinator) -> str | None:
+ grill_type, _ = _get_grill_info(coordinator)
+ if grill_type is None:
+ return OPTION_NONE
+ return grill_type.name.lower()
+
+ def _get_taste(coordinator: ToGrillCoordinator) -> str | None:
+ _, taste = _get_grill_info(coordinator)
+ if taste is None:
+ return OPTION_NONE
+ return taste.name.lower()
+
+ yield ToGrillSelectEntityDescription(
+ key=f"grill_type_{probe_number}",
+ translation_key="grill_type",
+ options=_get_enum_options(GrillType),
+ set_packet=_set_grill_type,
+ get_value=_get_grill_type,
+ entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
+ probe_number=probe_number,
+ )
+
+ yield ToGrillSelectEntityDescription(
+ key=f"taste_{probe_number}",
+ translation_key="taste",
+ options=_get_enum_options(Taste),
+ set_packet=_set_taste,
+ get_value=_get_taste,
+ entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT],
+ probe_number=probe_number,
+ )
+
+
+ENTITY_DESCRIPTIONS = (
+ *[
+ description
+ for probe_number in range(1, MAX_PROBE_COUNT + 1)
+ for description in _get_probe_descriptions(probe_number)
+ ],
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ToGrillConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up select based on a config entry."""
+
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ ToGrillSelect(coordinator, entity_description)
+ for entity_description in ENTITY_DESCRIPTIONS
+ if entity_description.entity_supported(entry.data)
+ )
+
+
+class ToGrillSelect(ToGrillEntity, SelectEntity):
+ """Representation of a select entity."""
+
+ entity_description: ToGrillSelectEntityDescription
+
+ def __init__(
+ self,
+ coordinator: ToGrillCoordinator,
+ entity_description: ToGrillSelectEntityDescription,
+ ) -> None:
+ """Initialize."""
+
+ super().__init__(coordinator, probe_number=entity_description.probe_number)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{coordinator.address}_{entity_description.key}"
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+
+ return self.entity_description.get_value(self.coordinator)
+
+ async def async_select_option(self, option: str) -> None:
+ """Set value on device."""
+
+ packet = self.entity_description.set_packet(self.coordinator, option)
+ await self._write_packet(packet)
diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py
index 1641236bfc1..0b85a09145c 100644
--- a/homeassistant/components/togrill/sensor.py
+++ b/homeassistant/components/togrill/sensor.py
@@ -34,6 +34,7 @@ class ToGrillSensorEntityDescription(SensorEntityDescription):
packet_type: int
packet_extract: Callable[[Packet], StateType]
entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True
+ probe_number: int | None = None
def _get_temperature_description(probe_number: int):
@@ -51,8 +52,6 @@ def _get_temperature_description(probe_number: int):
return ToGrillSensorEntityDescription(
key=f"temperature_{probe_number}",
- translation_key="temperature",
- translation_placeholders={"probe_number": f"{probe_number}"},
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
@@ -60,6 +59,7 @@ def _get_temperature_description(probe_number: int):
packet_type=PacketA1Notify.type,
packet_extract=_get,
entity_supported=_supported,
+ probe_number=probe_number,
)
@@ -109,9 +109,8 @@ class ToGrillSensor(ToGrillEntity, SensorEntity):
) -> None:
"""Initialize sensor."""
- super().__init__(coordinator)
+ super().__init__(coordinator, entity_description.probe_number)
self.entity_description = entity_description
- self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.address}_{entity_description.key}"
@property
diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json
index cef758b7d2e..5461ab52e93 100644
--- a/homeassistant/components/togrill/strings.json
+++ b/homeassistant/components/togrill/strings.json
@@ -22,6 +22,11 @@
"failed_to_read_config": "Failed to read config from device"
}
},
+ "device": {
+ "probe": {
+ "name": "Probe {probe_number}"
+ }
+ },
"exceptions": {
"disconnected": {
"message": "The device is disconnected"
@@ -34,14 +39,15 @@
}
},
"entity": {
- "sensor": {
- "temperature": {
- "name": "Probe {probe_number}"
- }
- },
"number": {
"temperature_target": {
- "name": "Target {probe_number}"
+ "name": "Target temperature"
+ },
+ "temperature_minimum": {
+ "name": "Minimum temperature"
+ },
+ "temperature_maximum": {
+ "name": "Maximum temperature"
},
"alarm_interval": {
"name": "Alarm interval"
@@ -49,7 +55,7 @@
},
"event": {
"event": {
- "name": "Probe {probe_number}",
+ "name": "Event",
"state_attributes": {
"event_type": {
"state": {
@@ -69,6 +75,40 @@
}
}
}
+ },
+ "select": {
+ "taste": {
+ "name": "Taste",
+ "state": {
+ "none": "Not set",
+ "rare": "Rare",
+ "medium_rare": "Medium rare",
+ "medium": "Medium",
+ "medium_well": "Medium well",
+ "well_done": "Well done"
+ }
+ },
+ "grill_type": {
+ "name": "Grill type",
+ "state": {
+ "none": "[%key:component::togrill::entity::select::taste::state::none%]",
+ "beef": "Beef",
+ "veal": "Veal",
+ "lamb": "Lamb",
+ "pork": "Pork",
+ "turkey": "Turkey",
+ "chicken": "Chicken",
+ "sausage": "Sausage",
+ "fish": "Fish",
+ "hamburger": "Hamburger",
+ "bbq_smoke": "BBQ smoke",
+ "hot_smoke": "Hot smoke",
+ "cold_smoke": "Cold smoke",
+ "mark_a": "Mark A",
+ "mark_b": "Mark B",
+ "mark_c": "Mark C"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py
index d2a43ef525b..bbd17cc8b13 100644
--- a/homeassistant/components/tolo/__init__.py
+++ b/homeassistant/components/tolo/__init__.py
@@ -2,12 +2,10 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -22,21 +20,17 @@ PLATFORMS = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ToloConfigEntry) -> bool:
"""Set up tolo from a config entry."""
coordinator = ToloSaunaUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: ToloConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py
index cb3ba46b604..0b94c60094f 100644
--- a/homeassistant/components/tolo/binary_sensor.py
+++ b/homeassistant/components/tolo/binary_sensor.py
@@ -4,23 +4,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
[
ToloFlowInBinarySensor(coordinator, entry),
@@ -37,7 +35,7 @@ class ToloFlowInBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.OPENING
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize TOLO Water In Valve entity."""
super().__init__(coordinator, entry)
@@ -58,7 +56,7 @@ class ToloFlowOutBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.OPENING
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize TOLO Water Out Valve entity."""
super().__init__(coordinator, entry)
diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py
index 9e4c8c84be9..472abdcb673 100644
--- a/homeassistant/components/tolo/button.py
+++ b/homeassistant/components/tolo/button.py
@@ -3,23 +3,21 @@
from tololib import LampMode
from homeassistant.components.button import ButtonEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
[
ToloLampNextColorButton(coordinator, entry),
@@ -34,7 +32,7 @@ class ToloLampNextColorButton(ToloSaunaCoordinatorEntity, ButtonEntity):
_attr_translation_key = "next_color"
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize lamp next color button entity."""
super().__init__(coordinator, entry)
diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py
index 0df8635fca9..ed7ab0c3b76 100644
--- a/homeassistant/components/tolo/climate.py
+++ b/homeassistant/components/tolo/climate.py
@@ -20,23 +20,21 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate controls for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([SaunaClimate(coordinator, entry)])
@@ -62,7 +60,7 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize TOLO Sauna Climate entity."""
super().__init__(coordinator, entry)
diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py
index fed4ff332fc..7b97fb20343 100644
--- a/homeassistant/components/tolo/config_flow.py
+++ b/homeassistant/components/tolo/config_flow.py
@@ -1,14 +1,19 @@
-"""Config flow for tolo."""
+"""Config flow for TOLO integration."""
from __future__ import annotations
import logging
+from types import MappingProxyType
from typing import Any
from tololib import ToloClient, ToloCommunicationError
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
@@ -17,13 +22,19 @@ from .const import DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ }
+)
-class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
- """ConfigFlow for TOLO Sauna."""
+
+class ToloConfigFlow(ConfigFlow, domain=DOMAIN):
+ """ConfigFlow for the TOLO Integration."""
VERSION = 1
- _discovered_host: str
+ _dhcp_discovery_info: DhcpServiceInfo | None = None
@staticmethod
def _check_device_availability(host: str) -> bool:
@@ -37,7 +48,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle a flow initialized by the user."""
+ """Handle a config flow initialized by the user."""
errors = {}
if user_input is not None:
@@ -47,19 +58,36 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
self._check_device_availability, user_input[CONF_HOST]
)
- if not device_available:
- errors["base"] = "cannot_connect"
- else:
- return self.async_create_entry(
- title=DEFAULT_NAME, data={CONF_HOST: user_input[CONF_HOST]}
- )
+ if device_available:
+ if self.source == SOURCE_RECONFIGURE:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data_updates=user_input
+ )
+ return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
+
+ errors["base"] = "cannot_connect"
+
+ schema_values: dict[str, Any] | MappingProxyType[str, Any] = {}
+ if user_input is not None:
+ schema_values = user_input
+ elif self.source == SOURCE_RECONFIGURE:
+ schema_values = self._get_reconfigure_entry().data
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(
+ CONFIG_SCHEMA,
+ schema_values,
+ ),
errors=errors,
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfiguration config flow initialized by the user."""
+ return await self.async_step_user(user_input)
+
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
@@ -73,7 +101,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
)
if device_available:
- self._discovered_host = discovery_info.ip
+ self._dhcp_discovery_info = discovery_info
return await self.async_step_confirm()
return self.async_abort(reason="not_tolo_device")
@@ -81,13 +109,15 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered node."""
+ assert self._dhcp_discovery_info is not None
+
if user_input is not None:
- self._async_abort_entries_match({CONF_HOST: self._discovered_host})
+ self._async_abort_entries_match({CONF_HOST: self._dhcp_discovery_info.ip})
return self.async_create_entry(
- title=DEFAULT_NAME, data={CONF_HOST: self._discovered_host}
+ title=DEFAULT_NAME, data={CONF_HOST: self._dhcp_discovery_info.ip}
)
return self.async_show_form(
step_id="confirm",
- description_placeholders={CONF_HOST: self._discovered_host},
+ description_placeholders={CONF_HOST: self._dhcp_discovery_info.ip},
)
diff --git a/homeassistant/components/tolo/coordinator.py b/homeassistant/components/tolo/coordinator.py
index 729073b16c4..372c67a4260 100644
--- a/homeassistant/components/tolo/coordinator.py
+++ b/homeassistant/components/tolo/coordinator.py
@@ -17,6 +17,8 @@ from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT
_LOGGER = logging.getLogger(__name__)
+type ToloConfigEntry = ConfigEntry[ToloSaunaUpdateCoordinator]
+
class ToloSaunaData(NamedTuple):
"""Compound class for reflecting full state (status and info) of a TOLO Sauna."""
@@ -28,9 +30,9 @@ class ToloSaunaData(NamedTuple):
class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]):
"""DataUpdateCoordinator for TOLO Sauna."""
- config_entry: ConfigEntry
+ config_entry: ToloConfigEntry
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: ToloConfigEntry) -> None:
"""Initialize ToloSaunaUpdateCoordinator."""
self.client = ToloClient(
address=entry.data[CONF_HOST],
diff --git a/homeassistant/components/tolo/entity.py b/homeassistant/components/tolo/entity.py
index 261cfc7cb0c..c6aef0fb824 100644
--- a/homeassistant/components/tolo/entity.py
+++ b/homeassistant/components/tolo/entity.py
@@ -2,12 +2,11 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]):
@@ -16,7 +15,7 @@ class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]):
_attr_has_entity_name = True
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize ToloSaunaCoordinatorEntity."""
super().__init__(coordinator)
diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py
index 7bddf775143..41ca94055ba 100644
--- a/homeassistant/components/tolo/fan.py
+++ b/homeassistant/components/tolo/fan.py
@@ -5,22 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up fan controls for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([ToloFan(coordinator, entry)])
@@ -31,7 +29,7 @@ class ToloFan(ToloSaunaCoordinatorEntity, FanEntity):
_attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize TOLO fan entity."""
super().__init__(coordinator, entry)
diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py
index 9ccd4a8e407..25e1e913544 100644
--- a/homeassistant/components/tolo/light.py
+++ b/homeassistant/components/tolo/light.py
@@ -5,22 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light controls for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([ToloLight(coordinator, entry)])
@@ -32,7 +30,7 @@ class ToloLight(ToloSaunaCoordinatorEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__(
- self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry
+ self, coordinator: ToloSaunaUpdateCoordinator, entry: ToloConfigEntry
) -> None:
"""Initialize TOLO Sauna Light entity."""
super().__init__(coordinator, entry)
diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py
index 902fb749d23..db06b82d002 100644
--- a/homeassistant/components/tolo/number.py
+++ b/homeassistant/components/tolo/number.py
@@ -15,13 +15,11 @@ from tololib import (
)
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
@@ -67,11 +65,11 @@ NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number controls for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
ToloNumberEntity(coordinator, entry, description) for description in NUMBERS
)
@@ -85,7 +83,7 @@ class ToloNumberEntity(ToloSaunaCoordinatorEntity, NumberEntity):
def __init__(
self,
coordinator: ToloSaunaUpdateCoordinator,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
entity_description: ToloNumberEntityDescription,
) -> None:
"""Initialize TOLO Number entity."""
diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py
index b08f37e40ae..f487fba9664 100644
--- a/homeassistant/components/tolo/select.py
+++ b/homeassistant/components/tolo/select.py
@@ -8,13 +8,12 @@ from dataclasses import dataclass
from tololib import ToloClient, ToloSettings
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN, AromaTherapySlot, LampMode
-from .coordinator import ToloSaunaUpdateCoordinator
+from .const import AromaTherapySlot, LampMode
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
@@ -53,11 +52,11 @@ SELECTS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
ToloSelectEntity(coordinator, entry, description) for description in SELECTS
)
@@ -73,7 +72,7 @@ class ToloSelectEntity(ToloSaunaCoordinatorEntity, SelectEntity):
def __init__(
self,
coordinator: ToloSaunaUpdateCoordinator,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
entity_description: ToloSelectEntityDescription,
) -> None:
"""Initialize TOLO select entity."""
diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py
index e97211c8e40..ba203dec806 100644
--- a/homeassistant/components/tolo/sensor.py
+++ b/homeassistant/components/tolo/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
@@ -23,8 +22,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
@@ -88,11 +86,11 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up (non-binary, general) sensors for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
ToloSensorEntity(coordinator, entry, description) for description in SENSORS
)
@@ -106,7 +104,7 @@ class ToloSensorEntity(ToloSaunaCoordinatorEntity, SensorEntity):
def __init__(
self,
coordinator: ToloSaunaUpdateCoordinator,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
entity_description: ToloSensorEntityDescription,
) -> None:
"""Initialize TOLO Number entity."""
diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json
index 82b6ecee9e7..55c8274c19b 100644
--- a/homeassistant/components/tolo/strings.json
+++ b/homeassistant/components/tolo/strings.json
@@ -16,7 +16,8 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py
index ce863053e26..686f78b04e9 100644
--- a/homeassistant/components/tolo/switch.py
+++ b/homeassistant/components/tolo/switch.py
@@ -9,12 +9,10 @@ from typing import Any
from tololib import ToloClient, ToloStatus
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import ToloSaunaUpdateCoordinator
+from .coordinator import ToloConfigEntry, ToloSaunaUpdateCoordinator
from .entity import ToloSaunaCoordinatorEntity
@@ -44,11 +42,11 @@ SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch controls for TOLO Sauna."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
ToloSwitchEntity(coordinator, entry, description) for description in SWITCHES
)
@@ -62,7 +60,7 @@ class ToloSwitchEntity(ToloSaunaCoordinatorEntity, SwitchEntity):
def __init__(
self,
coordinator: ToloSaunaUpdateCoordinator,
- entry: ConfigEntry,
+ entry: ToloConfigEntry,
entity_description: ToloSwitchEntityDescription,
) -> None:
"""Initialize TOLO switch entity."""
diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json
index 5140584f7ff..335559eeae9 100644
--- a/homeassistant/components/touchline_sl/manifest.json
+++ b/homeassistant/components/touchline_sl/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "requirements": ["pytouchlinesl==0.4.0"]
+ "requirements": ["pytouchlinesl==0.5.0"]
}
diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py
index 60bae9bfd2e..f00e0fec412 100644
--- a/homeassistant/components/tractive/__init__.py
+++ b/homeassistant/components/tractive/__init__.py
@@ -291,6 +291,7 @@ class TractiveClient:
for switch, key in SWITCH_KEY_MAP.items():
if switch_data := event.get(key):
payload[switch] = switch_data["active"]
+ payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING"
self._dispatch_tracker_event(
TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
)
diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py
index da2c8e35ff7..e4db6d69bee 100644
--- a/homeassistant/components/tractive/switch.py
+++ b/homeassistant/components/tractive/switch.py
@@ -18,6 +18,7 @@ from .const import (
ATTR_BUZZER,
ATTR_LED,
ATTR_LIVE_TRACKING,
+ ATTR_POWER_SAVING,
TRACKER_SWITCH_STATUS_UPDATED,
)
from .entity import TractiveEntity
@@ -104,7 +105,7 @@ class TractiveSwitch(TractiveEntity, SwitchEntity):
# We received an event, so the service is online and the switch entities should
# be available.
- self._attr_available = True
+ self._attr_available = not event[ATTR_POWER_SAVING]
self._attr_is_on = event[self.entity_description.key]
self.async_write_ha_state()
diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py
index 332ec9455eb..c274744a630 100644
--- a/homeassistant/components/trend/__init__.py
+++ b/homeassistant/components/trend/__init__.py
@@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_ENTITY_ID: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
async def source_entity_removed() -> None:
# The source entity has been removed, we remove the config entry because
@@ -57,7 +58,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -96,11 +96,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle an Trend options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py
index 3bb06ae3042..d8c2f1ba1a9 100644
--- a/homeassistant/components/trend/config_flow.py
+++ b/homeassistant/components/trend/config_flow.py
@@ -110,6 +110,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
options_flow = {
"init": SchemaFlowFormStep(get_extended_options_schema),
}
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/triggercmd/__init__.py b/homeassistant/components/triggercmd/__init__.py
index f58b2b481d4..3c1a2c855d0 100644
--- a/homeassistant/components/triggercmd/__init__.py
+++ b/homeassistant/components/triggercmd/__init__.py
@@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import httpx_client
from .const import CONF_TOKEN
@@ -20,9 +21,12 @@ type TriggercmdConfigEntry = ConfigEntry[ha.Hub]
async def async_setup_entry(hass: HomeAssistant, entry: TriggercmdConfigEntry) -> bool:
"""Set up TRIGGERcmd from a config entry."""
+ hass_client = httpx_client.get_async_client(hass)
hub = ha.Hub(entry.data[CONF_TOKEN])
-
- status_code = await client.async_connection_test(entry.data[CONF_TOKEN])
+ await hub.async_init(hass_client)
+ status_code = await client.async_connection_test(
+ entry.data[CONF_TOKEN], hass_client
+ )
if status_code != 200:
raise ConfigEntryNotReady
diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py
index 48c4eacfd5a..e796e836abf 100644
--- a/homeassistant/components/triggercmd/config_flow.py
+++ b/homeassistant/components/triggercmd/config_flow.py
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import httpx_client
from .const import CONF_TOKEN, DOMAIN
@@ -32,8 +33,9 @@ async def validate_input(hass: HomeAssistant, data: dict) -> str:
if not token_data["id"]:
raise InvalidToken
+ hass_client = httpx_client.get_async_client(hass)
try:
- await client.async_connection_test(data[CONF_TOKEN])
+ await client.async_connection_test(data[CONF_TOKEN], hass_client)
except Exception as e:
raise TRIGGERcmdConnectionError from e
else:
diff --git a/homeassistant/components/triggercmd/manifest.json b/homeassistant/components/triggercmd/manifest.json
index a0ee4eaf63e..1083c82e5be 100644
--- a/homeassistant/components/triggercmd/manifest.json
+++ b/homeassistant/components/triggercmd/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/triggercmd",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "requirements": ["triggercmd==0.0.27"]
+ "requirements": ["triggercmd==0.0.36"]
}
diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py
index e03ff333751..ae7b0d4beec 100644
--- a/homeassistant/components/triggercmd/switch.py
+++ b/homeassistant/components/triggercmd/switch.py
@@ -82,5 +82,6 @@ class TRIGGERcmdSwitch(SwitchEntity):
"params": params,
"sender": "Home Assistant",
},
+ self._switch.hub.httpx_client,
)
_LOGGER.debug("TRIGGERcmd trigger response: %s", r.json())
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index 629332d9d64..e1941250b64 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -12,10 +12,11 @@ import io
import logging
import mimetypes
import os
+from pathlib import Path
import re
import secrets
from time import monotonic
-from typing import Any, Final, Generic, Protocol, TypeVar
+from typing import Any, Final, Protocol
from aiohttp import web
import mutagen
@@ -125,6 +126,8 @@ KEY_PATTERN = "{0}_{1}_{2}_{3}"
SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({})
+FFMPEG_CHUNK_SIZE: Final[int] = 4096
+
class TTSCache:
"""Cached bytes of a TTS result."""
@@ -310,28 +313,31 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]:
async def _async_convert_audio(
hass: HomeAssistant,
- from_extension: str,
- audio_bytes_gen: AsyncGenerator[bytes],
- to_extension: str,
+ from_extension: str | None,
+ audio_input: AsyncGenerator[bytes] | str | Path,
+ to_extension: str | None,
to_sample_rate: int | None = None,
to_sample_channels: int | None = None,
to_sample_bytes: int | None = None,
) -> AsyncGenerator[bytes]:
"""Convert audio to a preferred format using ffmpeg."""
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
+ is_input_gen = not isinstance(audio_input, (str, Path))
+
+ command = [ffmpeg_manager.binary, "-hide_banner", "-loglevel", "error"]
+ if from_extension:
+ command.extend(["-f", from_extension])
+
+ if is_input_gen:
+ # Async generator
+ command.extend(["-i", "pipe:0"])
+ else:
+ # URL or path
+ command.extend(["-i", str(audio_input)])
+
+ if to_extension:
+ command.extend(["-f", to_extension])
- command = [
- ffmpeg_manager.binary,
- "-hide_banner",
- "-loglevel",
- "error",
- "-f",
- from_extension,
- "-i",
- "pipe:",
- "-f",
- to_extension,
- ]
if to_sample_rate is not None:
command.extend(["-ar", str(to_sample_rate)])
if to_sample_channels is not None:
@@ -346,36 +352,44 @@ async def _async_convert_audio(
process = await asyncio.create_subprocess_exec(
*command,
- stdin=asyncio.subprocess.PIPE,
+ stdin=asyncio.subprocess.PIPE if is_input_gen else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
- async def write_input() -> None:
- assert process.stdin
- try:
- async for chunk in audio_bytes_gen:
- process.stdin.write(chunk)
- await process.stdin.drain()
- finally:
- if process.stdin:
- process.stdin.close()
+ writer_task: asyncio.Task | None = None
- writer_task = hass.async_create_background_task(
- write_input(), "tts_ffmpeg_conversion"
- )
+ if is_input_gen:
+ # Input is a generator, so we must manually feed in chunks
+ assert isinstance(audio_input, AsyncGenerator)
+ assert process.stdin
+
+ async def write_input() -> None:
+ assert process.stdin
+ try:
+ async for chunk in audio_input:
+ process.stdin.write(chunk)
+ await process.stdin.drain()
+ finally:
+ if process.stdin:
+ process.stdin.close()
+
+ writer_task = hass.async_create_background_task(
+ write_input(), "tts_ffmpeg_conversion"
+ )
assert process.stdout
- chunk_size = 4096
try:
while True:
- chunk = await process.stdout.read(chunk_size)
+ chunk = await process.stdout.read(FFMPEG_CHUNK_SIZE)
if not chunk:
break
yield chunk
finally:
- # Ensure we wait for the input writer to complete.
- await writer_task
+ if writer_task is not None:
+ # Ensure we wait for the input writer to complete.
+ await writer_task
+
# Wait for process termination and check for errors.
retcode = await process.wait()
if retcode != 0:
@@ -470,6 +484,7 @@ class ResultStream:
"""Class that will stream the result when available."""
last_used: float = field(default_factory=monotonic, init=False)
+ hass: HomeAssistant
# Streaming/conversion properties
token: str
@@ -485,6 +500,9 @@ class ResultStream:
_manager: SpeechManager
+ # Override
+ _override_media_path: Path | None = None
+
@cached_property
def url(self) -> str:
"""Get the URL to stream the result."""
@@ -536,12 +554,63 @@ class ResultStream:
async def async_stream_result(self) -> AsyncGenerator[bytes]:
"""Get the stream of this result."""
+ if self._override_media_path is not None:
+ # Overridden
+ async for chunk in self._async_stream_override_result():
+ yield chunk
+
+ self.last_used = monotonic()
+ return
+
cache = await self._result_cache
async for chunk in cache.async_stream_data():
yield chunk
self.last_used = monotonic()
+ def async_override_result(self, media_path: str | Path) -> None:
+ """Override the TTS stream with a different media path."""
+ self._override_media_path = Path(media_path)
+
+ async def _async_stream_override_result(self) -> AsyncGenerator[bytes]:
+ """Get the stream of the overridden result."""
+ assert self._override_media_path is not None
+
+ preferred_format = self.options.get(ATTR_PREFERRED_FORMAT)
+ to_sample_rate = self.options.get(ATTR_PREFERRED_SAMPLE_RATE)
+ to_sample_channels = self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS)
+ to_sample_bytes = self.options.get(ATTR_PREFERRED_SAMPLE_BYTES)
+
+ needs_conversion = (
+ (preferred_format is not None)
+ or (to_sample_rate is not None)
+ or (to_sample_channels is not None)
+ or (to_sample_bytes is not None)
+ )
+
+ if not needs_conversion:
+ # Read file directly (no conversion)
+ yield await self.hass.async_add_executor_job(
+ self._override_media_path.read_bytes
+ )
+ return
+
+ # Use ffmpeg to convert audio to preferred format
+ if not preferred_format:
+ preferred_format = self._override_media_path.suffix[1:] # strip .
+
+ converted_audio = _async_convert_audio(
+ self.hass,
+ from_extension=None,
+ audio_input=self._override_media_path,
+ to_extension=preferred_format,
+ to_sample_rate=self.options.get(ATTR_PREFERRED_SAMPLE_RATE),
+ to_sample_channels=self.options.get(ATTR_PREFERRED_SAMPLE_CHANNELS),
+ to_sample_bytes=self.options.get(ATTR_PREFERRED_SAMPLE_BYTES),
+ )
+ async for chunk in converted_audio:
+ yield chunk
+
def _hash_options(options: dict) -> str:
"""Hashes an options dictionary."""
@@ -559,10 +628,7 @@ class HasLastUsed(Protocol):
last_used: float
-T = TypeVar("T", bound=HasLastUsed)
-
-
-class DictCleaning(Generic[T]):
+class DictCleaning[T: HasLastUsed]:
"""Helper to clean up the stale sessions."""
unsub: CALLBACK_TYPE | None = None
@@ -773,6 +839,7 @@ class SpeechManager:
language=language,
options=options,
supports_streaming_input=supports_streaming_input,
+ hass=self.hass,
_manager=self,
)
self.token_to_stream[token] = result_stream
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index 6ed8f0253ab..a01f3da1ba5 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -3,8 +3,7 @@
from __future__ import annotations
import logging
-from typing import TYPE_CHECKING, Any, NamedTuple
-from urllib.parse import urlsplit
+from typing import Any, NamedTuple
from tuya_sharing import (
CustomerDevice,
@@ -12,7 +11,6 @@ from tuya_sharing import (
SharingDeviceListener,
SharingTokenListener,
)
-from tuya_sharing.mq import SharingMQ, SharingMQConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -47,81 +45,13 @@ class HomeAssistantTuyaData(NamedTuple):
listener: SharingDeviceListener
-if TYPE_CHECKING:
- import paho.mqtt.client as mqtt
-
-
-class ManagerCompat(Manager):
- """Extended Manager class from the Tuya device sharing SDK.
-
- The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
- It overrides extend refresh_mq method to ensure correct paho.mqtt client calls.
-
- This code can be removed when a version of tuya-device-sharing with
- https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
- """
-
- def refresh_mq(self):
- """Refresh the MQTT connection."""
- if self.mq is not None:
- self.mq.stop()
- self.mq = None
-
- home_ids = [home.id for home in self.user_homes]
- device = [
- device
- for device in self.device_map.values()
- if hasattr(device, "id") and getattr(device, "set_up", False)
- ]
-
- sharing_mq = SharingMQCompat(self.customer_api, home_ids, device)
- sharing_mq.start()
- sharing_mq.add_message_listener(self.on_message)
- self.mq = sharing_mq
-
-
-class SharingMQCompat(SharingMQ):
- """Extended SharingMQ class from the Tuya device sharing SDK.
-
- The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
- It overrides _start method to ensure correct paho.mqtt client calls.
-
- This code can be removed when a version of tuya-device-sharing with
- https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
- """
-
- def _start(self, mq_config: SharingMQConfig) -> mqtt.Client:
- """Start the MQTT client."""
- # We don't import on the top because some integrations
- # should be able to optionally rely on MQTT.
- import paho.mqtt.client as mqtt # noqa: PLC0415
-
- mqttc = mqtt.Client(client_id=mq_config.client_id)
- mqttc.username_pw_set(mq_config.username, mq_config.password)
- mqttc.user_data_set({"mqConfig": mq_config})
- mqttc.on_connect = self._on_connect
- mqttc.on_message = self._on_message
- mqttc.on_subscribe = self._on_subscribe
- mqttc.on_log = self._on_log
- mqttc.on_disconnect = self._on_disconnect
-
- url = urlsplit(mq_config.url)
- if url.scheme == "ssl":
- mqttc.tls_set()
-
- mqttc.connect(url.hostname, url.port)
-
- mqttc.loop_start()
- return mqttc
-
-
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
if CONF_APP_TYPE in entry.data:
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
token_listener = TokenListener(hass, entry)
- manager = ManagerCompat(
+ manager = Manager(
TUYA_CLIENT_ID,
entry.data[CONF_USER_CODE],
entry.data[CONF_TERMINAL_ID],
@@ -154,8 +84,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool
device_registry = dr.async_get(hass)
for device in manager.device_map.values():
LOGGER.debug(
- "Register device %s: %s (function: %s, status range: %s)",
+ "Register device %s (online: %s): %s (function: %s, status range: %s)",
device.id,
+ device.online,
device.status,
device.function,
device.status_range,
@@ -227,19 +158,26 @@ class DeviceListener(SharingDeviceListener):
self.manager = manager
def update_device(
- self, device: CustomerDevice, updated_status_properties: list[str] | None
+ self,
+ device: CustomerDevice,
+ updated_status_properties: list[str] | None = None,
+ dp_timestamps: dict | None = None,
) -> None:
- """Update device status."""
+ """Update device status with optional DP timestamps."""
LOGGER.debug(
- "Received update for device %s: %s (updated properties: %s)",
+ "Received update for device %s (online: %s): %s"
+ " (updated properties: %s, dp_timestamps: %s)",
device.id,
- self.manager.device_map[device.id].status,
+ device.online,
+ device.status,
updated_status_properties,
+ dp_timestamps,
)
dispatcher_send(
self.hass,
f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}",
updated_status_properties,
+ dp_timestamps,
)
def add_device(self, device: CustomerDevice) -> None:
@@ -248,8 +186,9 @@ class DeviceListener(SharingDeviceListener):
self.hass.add_job(self.async_remove_device, device.id)
LOGGER.debug(
- "Add device %s: %s (function: %s, status range: %s)",
+ "Add device %s (online: %s): %s (function: %s, status range: %s)",
device.id,
+ device.online,
device.status,
device.function,
device.status_range,
diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py
index d08a3bef7ce..c35c1f8c3de 100644
--- a/homeassistant/components/tuya/alarm_control_panel.py
+++ b/homeassistant/components/tuya/alarm_control_panel.py
@@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData
from .util import get_dpcode
@@ -57,12 +57,8 @@ STATE_MAPPING: dict[str, AlarmControlPanelState] = {
}
-# All descriptions can be found here:
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-ALARM: dict[str, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = {
- # Alarm Host
- # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf
- "mal": (
+ALARM: dict[DeviceCategory, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = {
+ DeviceCategory.MAL: (
TuyaAlarmControlPanelEntityDescription(
key=DPCode.MASTER_MODE,
master_state=DPCode.MASTER_STATE,
@@ -79,23 +75,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya alarm dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya siren."""
entities: list[TuyaAlarmEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
- TuyaAlarmEntity(device, hass_data.manager, description)
+ TuyaAlarmEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
@@ -149,9 +145,11 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
"""Return the state of the device."""
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
- if self._master_state is not None:
- if self.device.status.get(self._master_state.dpcode) == State.ALARM:
- return AlarmControlPanelState.TRIGGERED
+ if (
+ self._master_state is not None
+ and self.device.status.get(self._master_state.dpcode) == State.ALARM
+ ):
+ return AlarmControlPanelState.TRIGGERED
if not (status := self.device.status.get(self.entity_description.key)):
return None
@@ -160,11 +158,13 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
@property
def changed_by(self) -> str | None:
"""Last change triggered by."""
- if self._master_state is not None and self._alarm_msg_dpcode is not None:
- if self.device.status.get(self._master_state.dpcode) == State.ALARM:
- encoded_msg = self.device.status.get(self._alarm_msg_dpcode)
- if encoded_msg:
- return b64decode(encoded_msg).decode("utf-16be")
+ if (
+ self._master_state is not None
+ and self._alarm_msg_dpcode is not None
+ and self.device.status.get(self._master_state.dpcode) == State.ALARM
+ and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
+ ):
+ return b64decode(encoded_msg).decode("utf-16be")
return None
def alarm_disarm(self, code: str | None = None) -> None:
diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py
index f9bc973f5a1..9a4be708880 100644
--- a/homeassistant/components/tuya/binary_sensor.py
+++ b/homeassistant/components/tuya/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
@@ -48,11 +48,8 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription(
# All descriptions can be found here. Mostly the Boolean data types in the
# default status set of each category (that don't have a set instruction)
# end up being a binary sensor.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
- # CO2 Detector
- # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
- "co2bj": (
+BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ...]] = {
+ DeviceCategory.CO2BJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CO2_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -60,9 +57,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # CO Detector
- # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
- "cobj": (
+ DeviceCategory.COBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CO_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -75,9 +70,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
- "cs": (
+ DeviceCategory.CS: (
TuyaBinarySensorEntityDescription(
key="tankfull",
dpcode=DPCode.FAULT,
@@ -103,18 +96,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
translation_key="wet",
),
),
- # Smart Pet Feeder
- # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
- "cwwsq": (
+ DeviceCategory.CWWSQ: (
TuyaBinarySensorEntityDescription(
key=DPCode.FEED_STATE,
translation_key="feeding",
on_value="feeding",
),
),
- # Multi-functional Sensor
- # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
- "dgnbj": (
+ DeviceCategory.DGNBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.GAS_SENSOR_STATE,
device_class=BinarySensorDeviceClass.GAS,
@@ -177,18 +166,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Human Presence Sensor
- # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs
- "hps": (
+ DeviceCategory.HPS: (
TuyaBinarySensorEntityDescription(
key=DPCode.PRESENCE_STATE,
device_class=BinarySensorDeviceClass.OCCUPANCY,
on_value={"presence", "small_move", "large_move", "peaceful"},
),
),
- # Formaldehyde Detector
- # Note: Not documented
- "jqbj": (
+ DeviceCategory.JQBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CH2O_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -196,9 +181,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Methane Detector
- # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm
- "jwbj": (
+ DeviceCategory.JWBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CH4_SENSOR_STATE,
device_class=BinarySensorDeviceClass.GAS,
@@ -206,9 +189,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Luminance Sensor
- # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
- "ldcg": (
+ DeviceCategory.LDCG: (
TuyaBinarySensorEntityDescription(
key=DPCode.TEMPER_ALARM,
device_class=BinarySensorDeviceClass.TAMPER,
@@ -216,18 +197,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Door and Window Controller
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9
- "mc": (
+ DeviceCategory.MC: (
TuyaBinarySensorEntityDescription(
key=DPCode.STATUS,
device_class=BinarySensorDeviceClass.DOOR,
on_value={"open", "opened"},
),
),
- # Door Window Sensor
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m
- "mcs": (
+ DeviceCategory.MCS: (
TuyaBinarySensorEntityDescription(
key=DPCode.DOORCONTACT_STATE,
device_class=BinarySensorDeviceClass.DOOR,
@@ -238,18 +215,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Access Control
- # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet
- "mk": (
+ DeviceCategory.MK: (
TuyaBinarySensorEntityDescription(
key=DPCode.CLOSED_OPENED_KIT,
device_class=BinarySensorDeviceClass.LOCK,
on_value={"AQAB"},
),
),
- # PIR Detector
- # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80
- "pir": (
+ DeviceCategory.PIR: (
TuyaBinarySensorEntityDescription(
key=DPCode.PIR,
device_class=BinarySensorDeviceClass.MOTION,
@@ -257,9 +230,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # PM2.5 Sensor
- # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu
- "pm2.5": (
+ DeviceCategory.PM2_5: (
TuyaBinarySensorEntityDescription(
key=DPCode.PM25_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -267,12 +238,8 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Temperature and Humidity Sensor with External Probe
- # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
- "qxj": (TAMPER_BINARY_SENSOR,),
- # Gas Detector
- # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw
- "rqbj": (
+ DeviceCategory.QXJ: (TAMPER_BINARY_SENSOR,),
+ DeviceCategory.RQBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.GAS_SENSOR_STATUS,
device_class=BinarySensorDeviceClass.GAS,
@@ -285,9 +252,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Water Detector
- # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
- "sj": (
+ DeviceCategory.SGBJ: (
+ TuyaBinarySensorEntityDescription(
+ key=DPCode.CHARGE_STATE,
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ ),
+ TAMPER_BINARY_SENSOR,
+ ),
+ DeviceCategory.SJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.WATERSENSOR_STATE,
device_class=BinarySensorDeviceClass.MOISTURE,
@@ -295,18 +267,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Emergency Button
- # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy
- "sos": (
+ DeviceCategory.SOS: (
TuyaBinarySensorEntityDescription(
key=DPCode.SOS_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
),
TAMPER_BINARY_SENSOR,
),
- # Volatile Organic Compound Sensor
- # Note: Undocumented in cloud API docs, based on test device
- "voc": (
+ DeviceCategory.VOC: (
TuyaBinarySensorEntityDescription(
key=DPCode.VOC_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -314,9 +282,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Gateway control
- # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok
- "wg2": (
+ DeviceCategory.WG2: (
TuyaBinarySensorEntityDescription(
key=DPCode.MASTER_STATE,
device_class=BinarySensorDeviceClass.PROBLEM,
@@ -324,39 +290,29 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
on_value="alarm",
),
),
- # Thermostat
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
- "wk": (
+ DeviceCategory.WK: (
TuyaBinarySensorEntityDescription(
key=DPCode.VALVE_STATE,
translation_key="valve",
on_value="open",
),
),
- # Thermostatic Radiator Valve
- # Not documented
- "wkf": (
+ DeviceCategory.WKF: (
TuyaBinarySensorEntityDescription(
key=DPCode.WINDOW_STATE,
device_class=BinarySensorDeviceClass.WINDOW,
on_value="opened",
),
),
- # Temperature and Humidity Sensor
- # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
- "wsdcg": (TAMPER_BINARY_SENSOR,),
- # Pressure Sensor
- # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
- "ylcg": (
+ DeviceCategory.WSDCG: (TAMPER_BINARY_SENSOR,),
+ DeviceCategory.YLCG: (
TuyaBinarySensorEntityDescription(
key=DPCode.PRESSURE_STATE,
on_value="alarm",
),
TAMPER_BINARY_SENSOR,
),
- # Smoke Detector
- # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
- "ywbj": (
+ DeviceCategory.YWBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.SMOKE_SENSOR_STATUS,
device_class=BinarySensorDeviceClass.SMOKE,
@@ -369,9 +325,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
- # Vibration Sensor
- # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
- "zd": (
+ DeviceCategory.ZD: (
TuyaBinarySensorEntityDescription(
key=f"{DPCode.SHOCK_STATE}_vibration",
dpcode=DPCode.SHOCK_STATE,
@@ -416,14 +370,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya binary sensor dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya binary sensor."""
entities: list[TuyaBinarySensorEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := BINARY_SENSORS.get(device.category):
for description in descriptions:
dpcode = description.dpcode or description.key
@@ -439,7 +393,7 @@ async def async_setup_entry(
entities.append(
TuyaBinarySensorEntity(
device,
- hass_data.manager,
+ manager,
description,
mask,
)
@@ -447,7 +401,7 @@ async def async_setup_entry(
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py
index 928e584e77d..013a02df048 100644
--- a/homeassistant/components/tuya/button.py
+++ b/homeassistant/components/tuya/button.py
@@ -11,23 +11,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
-# All descriptions can be found here.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = {
- # Wake Up Light II
- # Not documented
- "hxd": (
+BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
+ DeviceCategory.HXD: (
ButtonEntityDescription(
key=DPCode.SWITCH_USB6,
translation_key="snooze",
),
),
- # Robot Vacuum
- # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
- "sd": (
+ DeviceCategory.SD: (
ButtonEntityDescription(
key=DPCode.RESET_DUSTER_CLOTH,
translation_key="reset_duster_cloth",
@@ -63,24 +57,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya buttons dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya buttons."""
entities: list[TuyaButtonEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := BUTTONS.get(device.category):
entities.extend(
- TuyaButtonEntity(device, hass_data.manager, description)
+ TuyaButtonEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py
index 788a9bcc5c3..93525c723da 100644
--- a/homeassistant/components/tuya/camera.py
+++ b/homeassistant/components/tuya/camera.py
@@ -11,18 +11,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
-# All descriptions can be found here:
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-CAMERAS: tuple[str, ...] = (
- # Smart Camera - Low power consumption camera
- # Undocumented, see https://github.com/home-assistant/core/issues/132844
- "dghsxj",
- # Smart Camera (including doorbells)
- # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
- "sp",
+CAMERAS: tuple[DeviceCategory, ...] = (
+ DeviceCategory.DGHSXJ,
+ DeviceCategory.SP,
)
@@ -32,20 +26,20 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cameras dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya camera."""
entities: list[TuyaCameraEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if device.category in CAMERAS:
- entities.append(TuyaCameraEntity(device, hass_data.manager))
+ entities.append(TuyaCameraEntity(device, manager))
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
index ecfc96f1d67..ab1d8db16fa 100644
--- a/homeassistant/components/tuya/climate.py
+++ b/homeassistant/components/tuya/climate.py
@@ -24,7 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData
from .util import get_dpcode
@@ -48,40 +48,28 @@ class TuyaClimateEntityDescription(ClimateEntityDescription):
switch_only_hvac_mode: HVACMode
-CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = {
- # Electric Fireplace
- # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop
- "dbl": TuyaClimateEntityDescription(
+CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = {
+ DeviceCategory.DBL: TuyaClimateEntityDescription(
key="dbl",
switch_only_hvac_mode=HVACMode.HEAT,
),
- # Air conditioner
- # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
- "kt": TuyaClimateEntityDescription(
+ DeviceCategory.KT: TuyaClimateEntityDescription(
key="kt",
switch_only_hvac_mode=HVACMode.COOL,
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82
- "qn": TuyaClimateEntityDescription(
+ DeviceCategory.QN: TuyaClimateEntityDescription(
key="qn",
switch_only_hvac_mode=HVACMode.HEAT,
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx
- "rs": TuyaClimateEntityDescription(
+ DeviceCategory.RS: TuyaClimateEntityDescription(
key="rs",
switch_only_hvac_mode=HVACMode.HEAT,
),
- # Thermostat
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
- "wk": TuyaClimateEntityDescription(
+ DeviceCategory.WK: TuyaClimateEntityDescription(
key="wk",
switch_only_hvac_mode=HVACMode.HEAT_COOL,
),
- # Thermostatic Radiator Valve
- # Not documented
- "wkf": TuyaClimateEntityDescription(
+ DeviceCategory.WKF: TuyaClimateEntityDescription(
key="wkf",
switch_only_hvac_mode=HVACMode.HEAT,
),
@@ -94,26 +82,26 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya climate dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya climate."""
entities: list[TuyaClimateEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if device and device.category in CLIMATE_DESCRIPTIONS:
entities.append(
TuyaClimateEntity(
device,
- hass_data.manager,
+ manager,
CLIMATE_DESCRIPTIONS[device.category],
hass.config.units.temperature_unit,
)
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py
index 7a80a51726d..1f1f1ac626a 100644
--- a/homeassistant/components/tuya/const.py
+++ b/homeassistant/components/tuya/const.py
@@ -92,12 +92,516 @@ class DPType(StrEnum):
STRING = "String"
+class DeviceCategory(StrEnum):
+ """Tuya device categories.
+
+ https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
+ """
+
+ AMY = "amy"
+ """Massage chair"""
+ BGL = "bgl"
+ """Wall-hung boiler"""
+ BH = "bh"
+ """Smart kettle
+
+ https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7
+ """
+ BX = "bx"
+ """Refrigerator"""
+ BXX = "bxx"
+ """Safe box"""
+ CJKG = "cjkg"
+ """Scene switch"""
+ CKMKZQ = "ckmkzq"
+ """Garage door opener
+
+ https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
+ """
+ CKQDKG = "ckqdkg"
+ """Card switch"""
+ CL = "cl"
+ """Curtain
+
+ https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
+ """
+ CLKG = "clkg"
+ """Curtain switch
+
+ https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
+ """
+ CN = "cn"
+ """Milk dispenser"""
+ CO2BJ = "co2bj"
+ """CO2 detector
+
+ https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ """
+ COBJ = "cobj"
+ """CO detector
+
+ https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
+ """
+ CS = "cs"
+ """Dehumidifier
+
+ https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
+ """
+ CWTSWSQ = "cwtswsq"
+ """Pet treat feeder"""
+ CWWQFSQ = "cwwqfsq"
+ """Pet ball thrower"""
+ CWWSQ = "cwwsq"
+ """Pet feeder
+
+ https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
+ """
+ CWYSJ = "cwysj"
+ """Pet fountain
+
+ https://developer.tuya.com/en/docs/iot/categorycwysj?id=Kaiuz2dfro0nd
+ """
+ CZ = "cz"
+ """Socket"""
+ DBL = "dbl"
+ """Electric fireplace
+
+ https://developer.tuya.com/en/docs/iot/electric-fireplace?id=Kaiuz2hz4iyp6
+ """
+ DC = "dc"
+ """String lights
+
+ # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu
+ """
+ DCL = "dcl"
+ """Induction cooker"""
+ DD = "dd"
+ """Strip lights
+
+ https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l
+ """
+ DGNBJ = "dgnbj"
+ """Multi-functional alarm
+
+ https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
+ """
+ DJ = "dj"
+ """Light
+
+ https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy
+ """
+ DLQ = "dlq"
+ """Circuit breaker
+
+ https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8
+ """
+ DR = "dr"
+ """Electric blanket
+
+ https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p
+ """
+ DS = "ds"
+ """TV set"""
+ FS = "fs"
+ """Fan
+
+ https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
+ """
+ FSD = "fsd"
+ """Ceiling fan light
+
+ https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v
+ """
+ FWD = "fwd"
+ """Ambiance light
+
+ https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g
+ """
+ GGQ = "ggq"
+ """Irrigator
+
+ https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k
+ """
+ GYD = "gyd"
+ """Motion sensor light
+
+ https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy
+ """
+ GYMS = "gyms"
+ """Business lock"""
+ HOTELMS = "hotelms"
+ """Hotel lock"""
+ HPS = "hps"
+ """Human presence sensor
+
+ https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs
+ """
+ JS = "js"
+ """Water purifier"""
+ JSQ = "jsq"
+ """Humidifier
+
+ https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
+ """
+ JTMSBH = "jtmsbh"
+ """Smart lock (keep alive)"""
+ JTMSPRO = "jtmspro"
+ """Residential lock pro"""
+ JWBJ = "jwbj"
+ """Methane detector
+
+ https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm
+ """
+ KFJ = "kfj"
+ """Coffee maker
+
+ https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f
+ """
+ KG = "kg"
+ """Switch
+
+ https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
+ """
+ KJ = "kj"
+ """Air purifier
+
+ https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
+ """
+ KQZG = "kqzg"
+ """Air fryer"""
+ KT = "kt"
+ """Air conditioner
+
+ https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
+ """
+ KTKZQ = "ktkzq"
+ """Air conditioner controller"""
+ LDCG = "ldcg"
+ """Luminance sensor
+
+ https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
+ """
+ LILIAO = "liliao"
+ """Physiotherapy product"""
+ LYJ = "lyj"
+ """Drying rack"""
+ MAL = "mal"
+ """Alarm host
+
+ https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf
+ """
+ MB = "mb"
+ """Bread maker"""
+ MC = "mc"
+ """Door/window controller
+
+ https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9
+ """
+ MCS = "mcs"
+ """Contact sensor
+
+ https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m
+ """
+ MG = "mg"
+ """Rice cabinet"""
+ MJJ = "mjj"
+ """Towel rack"""
+ MK = "mk"
+ """Access control
+
+ https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet
+ """
+ MS = "ms"
+ """Residential lock"""
+ MS_CATEGORY = "ms_category"
+ """Lock accessories"""
+ MSP = "msp"
+ """Cat toilet
+
+ https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7
+ """
+ MZJ = "mzj"
+ """Sous vide cooker
+
+ https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux
+ """
+ NNQ = "nnq"
+ """Bottle warmer"""
+ NTQ = "ntq"
+ """HVAC"""
+ PC = "pc"
+ """Power strip
+
+ https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
+ """
+ PHOTOLOCK = "photolock"
+ """Audio and video lock"""
+ PIR = "pir"
+ """Human motion sensor
+
+ https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80
+ """
+ PM2_5 = "pm2.5"
+ """PM2.5 detector
+
+ https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu
+ """
+ QN = "qn"
+ """Heater
+
+ https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
+ """
+ RQBJ = "rqbj"
+ """Gas alarm
+
+ https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw
+ """
+ RS = "rs"
+ """Water heater
+
+ https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx
+ """
+ SB = "sb"
+ """Watch/band"""
+ SD = "sd"
+ """Robot vacuum
+
+ https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
+ """
+ SF = "sf"
+ """Sofa"""
+ SGBJ = "sgbj"
+ """Siren alarm
+
+ https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
+ """
+ SJ = "sj"
+ """Water leak detector
+
+ https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
+ """
+ SOS = "sos"
+ """Emergency button
+
+ https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy
+ """
+ SP = "sp"
+ """Smart camera
+
+ https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
+ """
+ SZ = "sz"
+ """Smart indoor garden
+
+ https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
+ """
+ TGKG = "tgkg"
+ """Dimmer switch
+
+ https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
+ """
+ TGQ = "tgq"
+ """Dimmer
+
+ https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
+ """
+ TNQ = "tnq"
+ """Smart milk kettle"""
+ TRACKER = "tracker"
+ """Tracker"""
+ TS = "ts"
+ """Smart jump rope"""
+ TYNDJ = "tyndj"
+ """Solar light
+
+ https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
+ """
+ TYY = "tyy"
+ """Projector"""
+ TZC1 = "tzc1"
+ """Body fat scale"""
+ VIDEOLOCK = "videolock"
+ """Lock with camera"""
+ WK = "wk"
+ """Thermostat
+
+ https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
+ """
+ WSDCG = "wsdcg"
+ """Temperature and humidity sensor
+
+ https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
+ """
+ XDD = "xdd"
+ """Ceiling light
+
+ https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r
+ """
+ XFJ = "xfj"
+ """Ventilation system"""
+ XXJ = "xxj"
+ """Diffuser
+
+ https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl
+ """
+ XY = "xy"
+ """Washing machine"""
+ YB = "yb"
+ """Bathroom heater"""
+ YG = "yg"
+ """Bathtub"""
+ YKQ = "ykq"
+ """Remote control
+
+ https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov
+ """
+ YLCG = "ylcg"
+ """Pressure sensor
+
+ https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
+ """
+ YWBJ = "ywbj"
+ """Smoke alarm
+
+ https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
+ """
+ ZD = "zd"
+ """Vibration sensor
+
+ https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
+ """
+ ZNDB = "zndb"
+ """Smart electricity meter
+
+ https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7
+ """
+ ZNFH = "znfh"
+ """Bento box"""
+ ZNSB = "znsb"
+ """Smart water meter"""
+ ZNYH = "znyh"
+ """Smart pill box"""
+
+ # Undocumented
+ AQCZ = "aqcz"
+ """Single Phase power meter (undocumented)"""
+ BZYD = "bzyd"
+ """White noise machine (undocumented)"""
+ CWJWQ = "cwjwq"
+ """Smart Odor Eliminator-Pro (undocumented)
+
+ see https://github.com/orgs/home-assistant/discussions/79
+ """
+ DGHSXJ = "dghsxj"
+ """Smart Camera - Low power consumption camera (undocumented)
+
+ see https://github.com/home-assistant/core/issues/132844
+ """
+ DSD = "dsd"
+ """Filament Light
+
+ Based on data from https://github.com/home-assistant/core/issues/106703
+ Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6
+ As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc
+ """
+ FSKG = "fskg"
+ """Fan wall switch (undocumented)"""
+ HJJCY = "hjjcy"
+ """Air Quality Monitor
+
+ https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv
+ """
+ HXD = "hxd"
+ """Wake Up Light II (undocumented)"""
+ JDCLJQR = "jdcljqr"
+ """Curtain Robot (undocumented)"""
+ JQBJ = "jqbj"
+ """Formaldehyde Detector (undocumented)"""
+ KS = "ks"
+ """Tower fan (undocumented)
+
+ See https://github.com/orgs/home-assistant/discussions/329
+ """
+ MBD = "mbd"
+ """Unknown light product
+
+ Found as VECINO RGBW as provided by diagnostics
+ """
+ QCCDZ = "qccdz"
+ """AC charging (undocumented)"""
+ QJDCZ = "qjdcz"
+ """ Unknown product with light capabilities
+
+ Found in some diffusers, plugs and PIR flood lights
+ """
+ QXJ = "qxj"
+ """Temperature and Humidity Sensor with External Probe (undocumented)
+
+ see https://github.com/home-assistant/core/issues/136472
+ """
+ SFKZQ = "sfkzq"
+ """Smart Water Timer (undocumented)"""
+ SJZ = "sjz"
+ """Electric desk (undocumented)"""
+ SZJCY = "szjcy"
+ """Water tester (undocumented)"""
+ SZJQR = "szjqr"
+ """Fingerbot (undocumented)"""
+ SWTZ = "swtz"
+ """Cooking thermometer (undocumented)"""
+ TDQ = "tdq"
+ """Dimmer (undocumented)"""
+ TYD = "tyd"
+ """Outdoor flood light (undocumented)"""
+ VOC = "voc"
+ """Volatile Organic Compound Sensor (undocumented)"""
+ WG2 = "wg2" # Documented, but not in official list
+ """Gateway control
+
+ https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok
+ """
+ WKCZ = "wkcz"
+ """Two-way temperature and humidity switch (undocumented)
+
+ "MOES Temperature and Humidity Smart Switch Module MS-103"
+ """
+ WKF = "wkf"
+ """Thermostatic Radiator Valve (undocumented)"""
+ WNYKQ = "wnykq"
+ """Smart WiFi IR Remote (undocumented)
+
+ eMylo Smart WiFi IR Remote
+ Air Conditioner Mate (Smart IR Socket)
+ """
+ WXKG = "wxkg" # Documented, but not in official list
+ """Wireless Switch
+
+ https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp
+ """
+ XNYJCN = "xnyjcn"
+ """Micro Storage Inverter
+
+ Energy storage and solar PV inverter system with monitoring capabilities
+ """
+ YWCGQ = "ywcgq"
+ """Tank Level Sensor (undocumented)"""
+ ZNNBQ = "znnbq"
+ """VESKA-micro inverter (undocumented)"""
+ ZWJCY = "zwjcy"
+ """Soil sensor - plant monitor (undocumented)"""
+ ZNJXS = "znjxs"
+ """Hejhome whitelabel Fingerbot (undocumented)"""
+ ZNRB = "znrb"
+ """Pool HeatPump (undocumented)"""
+
+
class DPCode(StrEnum):
"""Data Point Codes used by Tuya.
https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
"""
+ ADD_ELE = "add_ele" # energy
AIR_QUALITY = "air_quality"
AIR_QUALITY_INDEX = "air_quality_index"
ALARM_DELAY_TIME = "alarm_delay_time"
@@ -112,6 +616,7 @@ class DPCode(StrEnum):
ARM_DOWN_PERCENT = "arm_down_percent"
ARM_UP_PERCENT = "arm_up_percent"
ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API
+ BACKUP_RESERVE = "backup_reserve"
BASIC_ANTI_FLICKER = "basic_anti_flicker"
BASIC_DEVICE_VOLUME = "basic_device_volume"
BASIC_FLIP = "basic_flip"
@@ -122,6 +627,7 @@ class DPCode(StrEnum):
BASIC_WDR = "basic_wdr"
BATTERY = "battery" # Used by non-standard contact sensor implementations
BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage
+ BATTERY_POWER = "battery_power"
BATTERY_STATE = "battery_state" # Battery state
BATTERY_VALUE = "battery_value" # Battery value
BRIGHT_CONTROLLER = "bright_controller"
@@ -138,10 +644,12 @@ class DPCode(StrEnum):
BRIGHTNESS_MIN_2 = "brightness_min_2"
BRIGHTNESS_MIN_3 = "brightness_min_3"
C_F = "c_f" # Temperature unit switching
+ CAT_WEIGHT = "cat_weight"
CH2O_STATE = "ch2o_state"
CH2O_VALUE = "ch2o_value"
CH4_SENSOR_STATE = "ch4_sensor_state"
CH4_SENSOR_VALUE = "ch4_sensor_value"
+ CHARGE_STATE = "charge_state"
CHILD_LOCK = "child_lock" # Child lock
CISTERN = "cistern"
CLEAN_AREA = "clean_area"
@@ -166,6 +674,7 @@ class DPCode(StrEnum):
CONTROL_BACK = "control_back"
CONTROL_BACK_MODE = "control_back_mode"
COOK_TEMPERATURE = "cook_temperature"
+ COOK_TEMPERATURE_2 = "cook_temperature_2"
COOK_TIME = "cook_time"
COUNTDOWN = "countdown" # Countdown
COUNTDOWN_1 = "countdown_1"
@@ -179,16 +688,23 @@ class DPCode(StrEnum):
COUNTDOWN_LEFT = "countdown_left"
COUNTDOWN_SET = "countdown_set" # Countdown setting
CRY_DETECTION_SWITCH = "cry_detection_switch"
+ CUML_E_EXPORT_OFFGRID1 = "cuml_e_export_offgrid1"
+ CUMULATIVE_ENERGY_CHARGED = "cumulative_energy_charged"
+ CUMULATIVE_ENERGY_DISCHARGED = "cumulative_energy_discharged"
+ CUMULATIVE_ENERGY_GENERATED_PV = "cumulative_energy_generated_pv"
+ CUMULATIVE_ENERGY_OUTPUT_INV = "cumulative_energy_output_inv"
CUP_NUMBER = "cup_number" # NUmber of cups
CUR_CURRENT = "cur_current" # Actual current
CUR_NEUTRAL = "cur_neutral" # Total reverse energy
CUR_POWER = "cur_power" # Actual power
CUR_VOLTAGE = "cur_voltage" # Actual voltage
+ CURRENT_SOC = "current_soc"
DECIBEL_SENSITIVITY = "decibel_sensitivity"
DECIBEL_SWITCH = "decibel_switch"
DEHUMIDITY_SET_ENUM = "dehumidify_set_enum"
DEHUMIDITY_SET_VALUE = "dehumidify_set_value"
DELAY_SET = "delay_set"
+ DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection"
DO_NOT_DISTURB = "do_not_disturb"
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
@@ -212,6 +728,8 @@ class DPCode(StrEnum):
FAULT = "fault"
FEED_REPORT = "feed_report"
FEED_STATE = "feed_state"
+ FEEDIN_POWER_LIMIT_ENABLE = "feedin_power_limit_enable"
+ FEELLIKE_TEMP = "feellike_temp"
FILTER = "filter"
FILTER_DURATION = "filter_life" # Filter duration (hours)
FILTER_LIFE = "filter" # Filter life (percentage)
@@ -223,6 +741,7 @@ class DPCode(StrEnum):
GAS_SENSOR_STATE = "gas_sensor_state"
GAS_SENSOR_STATUS = "gas_sensor_status"
GAS_SENSOR_VALUE = "gas_sensor_value"
+ HEAT_INDEX = "heat_index"
HUMIDIFIER = "humidifier" # Humidification
HUMIDITY = "humidity" # Humidity
HUMIDITY_CURRENT = "humidity_current" # Current humidity
@@ -234,6 +753,7 @@ class DPCode(StrEnum):
HUMIDITY_SET = "humidity_set" # Humidity setting
HUMIDITY_VALUE = "humidity_value" # Humidity
INSTALLATION_HEIGHT = "installation_height"
+ INVERTER_OUTPUT_POWER = "inverter_output_power"
IPC_WORK_MODE = "ipc_work_mode"
LED_TYPE_1 = "led_type_1"
LED_TYPE_2 = "led_type_2"
@@ -266,6 +786,7 @@ class DPCode(StrEnum):
MUFFLING = "muffling" # Muffling
NEAR_DETECTION = "near_detection"
OPPOSITE = "opposite"
+ OUTPUT_POWER_LIMIT = "output_power_limit"
OXYGEN = "oxygen" # Oxygen bar
PAUSE = "pause"
PERCENT_CONTROL = "percent_control"
@@ -294,9 +815,13 @@ class DPCode(StrEnum):
PRESENCE_STATE = "presence_state"
PRESSURE_STATE = "pressure_state"
PRESSURE_VALUE = "pressure_value"
+ PRO_ADD_ELE = "pro_add_ele" # Produce energy
PUMP = "pump"
PUMP_RESET = "pump_reset" # Water pump reset
PUMP_TIME = "pump_time" # Water pump duration
+ PV_POWER_CHANNEL_1 = "pv_power_channel_1"
+ PV_POWER_CHANNEL_2 = "pv_power_channel_2"
+ PV_POWER_TOTAL = "pv_power_total"
RAIN_24H = "rain_24h" # Total daily rainfall in mm
RAIN_RATE = "rain_rate" # Rain intensity in mm/h
RECORD_MODE = "record_mode"
@@ -323,6 +848,7 @@ class DPCode(StrEnum):
SMOKE_SENSOR_STATE = "smoke_sensor_state"
SMOKE_SENSOR_STATUS = "smoke_sensor_status"
SMOKE_SENSOR_VALUE = "smoke_sensor_value"
+ SNOOZE = "snooze"
SOS = "sos" # Emergency State
SOS_STATE = "sos_state" # Emergency mode
SPEED = "speed" # Speed level
@@ -363,6 +889,7 @@ class DPCode(StrEnum):
SWITCH_MODE7 = "switch_mode7"
SWITCH_MODE8 = "switch_mode8"
SWITCH_MODE9 = "switch_mode9"
+ SWITCH_MUSIC = "switch_music"
SWITCH_NIGHT_LIGHT = "switch_night_light"
SWITCH_SAVE_ENERGY = "switch_save_energy"
SWITCH_SOUND = "switch_sound" # Voice switch
@@ -376,12 +903,14 @@ class DPCode(StrEnum):
SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch
SWITCH_VOICE = "switch_voice" # Voice switch
TARGET_DIS_CLOSEST = "target_dis_closest" # Closest target distance
+ TDS_IN = "tds_in" # Total dissolved solids
TEMP = "temp" # Temperature setting
TEMP_BOILING_C = "temp_boiling_c"
TEMP_BOILING_F = "temp_boiling_f"
TEMP_CONTROLLER = "temp_controller"
TEMP_CORRECTION = "temp_correction"
TEMP_CURRENT = "temp_current" # Current temperature in °C
+ TEMP_CURRENT_2 = "temp_current_2"
TEMP_CURRENT_EXTERNAL = (
"temp_current_external" # Current external temperature in Celsius
)
@@ -415,6 +944,7 @@ class DPCode(StrEnum):
TOTAL_POWER = "total_power"
TOTAL_TIME = "total_time"
TVOC = "tvoc"
+ UP_DOWN = "up_down"
UPPER_TEMP = "upper_temp"
UPPER_TEMP_F = "upper_temp_f"
UV = "uv" # UV sterilization
@@ -441,6 +971,7 @@ class DPCode(StrEnum):
WET = "wet" # Humidification
WINDOW_CHECK = "window_check"
WINDOW_STATE = "window_state"
+ WINDCHILL_INDEX = "windchill_index"
WINDSPEED = "windspeed"
WINDSPEED_AVG = "windspeed_avg"
WIND_DIRECT = "wind_direct"
diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py
index 43e3f20deb4..16fa9f294ea 100644
--- a/homeassistant/components/tuya/cover.py
+++ b/homeassistant/components/tuya/cover.py
@@ -20,9 +20,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
-from .models import IntegerTypeData
+from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
@@ -30,19 +30,18 @@ from .util import get_dpcode
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe an Tuya cover entity."""
- current_state: DPCode | None = None
+ current_state: DPCode | tuple[DPCode, ...] | None = None
current_state_inverse: bool = False
current_position: DPCode | tuple[DPCode, ...] | None = None
set_position: DPCode | None = None
open_instruction_value: str = "open"
close_instruction_value: str = "close"
stop_instruction_value: str = "stop"
+ motor_reverse_mode: DPCode | None = None
-COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
- # Garage Door Opener
- # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
- "ckmkzq": (
+COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
+ DeviceCategory.CKMKZQ: (
TuyaCoverEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_door",
@@ -68,14 +67,11 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
device_class=CoverDeviceClass.GARAGE,
),
),
- # Curtain
- # Note: Multiple curtains isn't documented
- # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
- "cl": (
+ DeviceCategory.CL: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
- current_state=DPCode.SITUATION_SET,
+ current_state=(DPCode.SITUATION_SET, DPCode.CONTROL),
current_position=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL),
set_position=DPCode.PERCENT_CONTROL,
device_class=CoverDeviceClass.CURTAIN,
@@ -116,14 +112,13 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
device_class=CoverDeviceClass.BLIND,
),
),
- # Curtain Switch
- # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
- "clkg": (
+ DeviceCategory.CLKG: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
current_position=DPCode.PERCENT_CONTROL,
set_position=DPCode.PERCENT_CONTROL,
+ motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
TuyaCoverEntityDescription(
@@ -132,12 +127,11 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
translation_placeholders={"index": "2"},
current_position=DPCode.PERCENT_CONTROL_2,
set_position=DPCode.PERCENT_CONTROL_2,
+ motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
),
- # Curtain Robot
- # Note: Not documented
- "jdcljqr": (
+ DeviceCategory.JDCLJQR: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
@@ -155,17 +149,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cover dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya cover."""
entities: list[TuyaCoverEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
entities.extend(
- TuyaCoverEntity(device, hass_data.manager, description)
+ TuyaCoverEntity(device, manager, description)
for description in descriptions
if (
description.key in device.function
@@ -175,7 +169,7 @@ async def async_setup_entry(
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
@@ -186,8 +180,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
"""Tuya Cover Device."""
_current_position: IntegerTypeData | None = None
+ _current_state: DPCode | None = None
_set_position: IntegerTypeData | None = None
_tilt: IntegerTypeData | None = None
+ _motor_reverse_mode_enum: EnumTypeData | None = None
entity_description: TuyaCoverEntityDescription
def __init__(
@@ -218,6 +214,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if description.stop_instruction_value in enum_type.range:
self._attr_supported_features |= CoverEntityFeature.STOP
+ self._current_state = get_dpcode(self.device, description.current_state)
+
# Determine type to use for setting the position
if int_type := self.find_dpcode(
description.set_position, dptype=DPType.INTEGER, prefer_function=True
@@ -242,6 +240,26 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
self._tilt = int_type
+ # Determine type to use for checking motor reverse mode
+ if (motor_mode := description.motor_reverse_mode) and (
+ enum_type := self.find_dpcode(
+ motor_mode,
+ dptype=DPType.ENUM,
+ prefer_function=True,
+ )
+ ):
+ self._motor_reverse_mode_enum = enum_type
+
+ @property
+ def _is_position_reversed(self) -> bool:
+ """Check if the cover position and direction should be reversed."""
+ # The default is True
+ # Having motor_reverse_mode == "back" cancels the inversion
+ return not (
+ self._motor_reverse_mode_enum
+ and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
+ )
+
@property
def current_cover_position(self) -> int | None:
"""Return cover current position."""
@@ -252,7 +270,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
return None
return round(
- self._current_position.remap_value_to(position, 0, 100, reverse=True)
+ self._current_position.remap_value_to(
+ position, 0, 100, reverse=self._is_position_reversed
+ )
)
@property
@@ -272,22 +292,19 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return true if cover is closed."""
+ # If it's available, prefer the position over the current state
+ if (position := self.current_cover_position) is not None:
+ return position == 0
+
if (
- self.entity_description.current_state is not None
- and (
- current_state := self.device.status.get(
- self.entity_description.current_state
- )
- )
+ self._current_state is not None
+ and (current_state := self.device.status.get(self._current_state))
is not None
):
return self.entity_description.current_state_inverse is not (
current_state in (True, "fully_close")
)
- if (position := self.current_cover_position) is not None:
- return position == 0
-
return None
def open_cover(self, **kwargs: Any) -> None:
@@ -307,7 +324,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
{
"code": self._set_position.dpcode,
"value": round(
- self._set_position.remap_value_from(100, 0, 100, reverse=True),
+ self._set_position.remap_value_from(
+ 100, 0, 100, reverse=self._is_position_reversed
+ ),
),
}
)
@@ -331,7 +350,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
{
"code": self._set_position.dpcode,
"value": round(
- self._set_position.remap_value_from(0, 0, 100, reverse=True),
+ self._set_position.remap_value_from(
+ 0, 0, 100, reverse=self._is_position_reversed
+ ),
),
}
)
@@ -350,7 +371,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
- kwargs[ATTR_POSITION], 0, 100, reverse=True
+ kwargs[ATTR_POSITION],
+ 0,
+ 100,
+ reverse=self._is_position_reversed,
)
),
}
@@ -380,7 +404,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
"code": self._tilt.dpcode,
"value": round(
self._tilt.remap_value_from(
- kwargs[ATTR_TILT_POSITION], 0, 100, reverse=True
+ kwargs[ATTR_TILT_POSITION],
+ 0,
+ 100,
+ reverse=self._is_position_reversed,
)
),
}
diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py
index 9675b215ce2..b71a17f68a6 100644
--- a/homeassistant/components/tuya/diagnostics.py
+++ b/homeassistant/components/tuya/diagnostics.py
@@ -39,15 +39,15 @@ def _async_get_diagnostics(
device: DeviceEntry | None = None,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
mqtt_connected = None
- if hass_data.manager.mq.client:
- mqtt_connected = hass_data.manager.mq.client.is_connected()
+ if manager.mq.client:
+ mqtt_connected = manager.mq.client.is_connected()
data = {
- "endpoint": hass_data.manager.customer_api.endpoint,
- "terminal_id": hass_data.manager.terminal_id,
+ "endpoint": manager.customer_api.endpoint,
+ "terminal_id": manager.terminal_id,
"mqtt_connected": mqtt_connected,
"disabled_by": entry.disabled_by,
"disabled_polling": entry.pref_disable_polling,
@@ -55,14 +55,12 @@ def _async_get_diagnostics(
if device:
tuya_device_id = next(iter(device.identifiers))[1]
- data |= _async_device_as_dict(
- hass, hass_data.manager.device_map[tuya_device_id]
- )
+ data |= _async_device_as_dict(hass, manager.device_map[tuya_device_id])
else:
data.update(
devices=[
_async_device_as_dict(hass, device)
- for device in hass_data.manager.device_map.values()
+ for device in manager.device_map.values()
]
)
diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py
index 0ae0f793afd..1ed9aae1f22 100644
--- a/homeassistant/components/tuya/entity.py
+++ b/homeassistant/components/tuya/entity.py
@@ -126,7 +126,7 @@ class TuyaEntity(Entity):
return None
def get_dptype(
- self, dpcode: DPCode | None, prefer_function: bool = False
+ self, dpcode: DPCode | None, *, prefer_function: bool = False
) -> DPType | None:
"""Find a matching DPCode data type available on for this device."""
if dpcode is None:
@@ -158,7 +158,9 @@ class TuyaEntity(Entity):
)
async def _handle_state_update(
- self, updated_status_properties: list[str] | None
+ self,
+ updated_status_properties: list[str] | None,
+ dp_timestamps: dict | None = None,
) -> None:
self.async_write_ha_state()
diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py
index 09ab8e8f544..4cfb22e4cce 100644
--- a/homeassistant/components/tuya/event.py
+++ b/homeassistant/components/tuya/event.py
@@ -14,17 +14,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
# All descriptions can be found here. Mostly the Enum data types in the
# default status set of each category (that don't have a set instruction)
# end up being events.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-EVENTS: dict[str, tuple[EventEntityDescription, ...]] = {
- # Wireless Switch
- # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp
- "wxkg": (
+EVENTS: dict[DeviceCategory, tuple[EventEntityDescription, ...]] = {
+ DeviceCategory.WXKG: (
EventEntityDescription(
key=DPCode.SWITCH_MODE1,
device_class=EventDeviceClass.BUTTON,
@@ -89,25 +86,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya events dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya binary sensor."""
entities: list[TuyaEventEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := EVENTS.get(device.category):
for description in descriptions:
dpcode = description.key
if dpcode in device.status:
- entities.append(
- TuyaEventEntity(device, hass_data.manager, description)
- )
+ entities.append(TuyaEventEntity(device, manager, description))
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
@@ -134,7 +129,9 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
self._attr_event_types: list[str] = dpcode.range
async def _handle_state_update(
- self, updated_status_properties: list[str] | None
+ self,
+ updated_status_properties: list[str] | None,
+ dp_timestamps: dict | None = None,
) -> None:
if (
updated_status_properties is None
diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py
index 12b6b11a297..db16720ddc4 100644
--- a/homeassistant/components/tuya/fan.py
+++ b/homeassistant/components/tuya/fan.py
@@ -21,7 +21,7 @@ from homeassistant.util.percentage import (
)
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
@@ -36,24 +36,13 @@ _SPEED_DPCODES = (
)
_SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH)
-TUYA_SUPPORT_TYPE = {
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
- "cs",
- # Fan
- # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
- "fs",
- # Ceiling Fan Light
- # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v
- "fsd",
- # Fan wall switch
- "fskg",
- # Air Purifier
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
- "kj",
- # Undocumented tower fan
- # https://github.com/orgs/home-assistant/discussions/329
- "ks",
+TUYA_SUPPORT_TYPE: set[DeviceCategory] = {
+ DeviceCategory.CS,
+ DeviceCategory.FS,
+ DeviceCategory.FSD,
+ DeviceCategory.FSKG,
+ DeviceCategory.KJ,
+ DeviceCategory.KS,
}
@@ -76,19 +65,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya fan dynamically through tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya fan."""
entities: list[TuyaFanEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device):
- entities.append(TuyaFanEntity(device, hass_data.manager))
+ entities.append(TuyaFanEntity(device, manager))
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py
index cb08ccaf476..cc6fdd778fe 100644
--- a/homeassistant/components/tuya/humidifier.py
+++ b/homeassistant/components/tuya/humidifier.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError, get_dpcode
@@ -49,19 +49,15 @@ def _has_a_valid_dpcode(
return any(get_dpcode(device, code) for code in properties_to_check)
-HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = {
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
- "cs": TuyaHumidifierEntityDescription(
+HUMIDIFIERS: dict[DeviceCategory, TuyaHumidifierEntityDescription] = {
+ DeviceCategory.CS: TuyaHumidifierEntityDescription(
key=DPCode.SWITCH,
dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY),
current_humidity=DPCode.HUMIDITY_INDOOR,
humidity=DPCode.DEHUMIDITY_SET_VALUE,
device_class=HumidifierDeviceClass.DEHUMIDIFIER,
),
- # Humidifier
- # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
- "jsq": TuyaHumidifierEntityDescription(
+ DeviceCategory.JSQ: TuyaHumidifierEntityDescription(
key=DPCode.SWITCH,
dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY),
current_humidity=DPCode.HUMIDITY_CURRENT,
@@ -77,23 +73,21 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya (de)humidifier dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya (de)humidifier."""
entities: list[TuyaHumidifierEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if (
description := HUMIDIFIERS.get(device.category)
) and _has_a_valid_dpcode(device, description):
- entities.append(
- TuyaHumidifierEntity(device, hass_data.manager, description)
- )
+ entities.append(TuyaHumidifierEntity(device, manager, description))
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index 673e9b1ffb3..d2cceaa4620 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
from .entity import TuyaEntity
from .models import IntegerTypeData
from .util import get_dpcode, remap_value
@@ -72,19 +72,23 @@ class TuyaLightEntityDescription(LightEntityDescription):
)
-LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
- # Curtain Switch
- # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
- "clkg": (
+LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
+ DeviceCategory.BZYD: (
+ TuyaLightEntityDescription(
+ key=DPCode.SWITCH_LED,
+ name=None,
+ color_mode=DPCode.WORK_MODE,
+ color_data=DPCode.COLOUR_DATA,
+ ),
+ ),
+ DeviceCategory.CLKG: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_BACKLIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # String Lights
- # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu
- "dc": (
+ DeviceCategory.DC: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -94,9 +98,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Strip Lights
- # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l
- "dd": (
+ DeviceCategory.DD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -107,9 +109,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
default_color_type=DEFAULT_COLOR_TYPE_DATA_V2,
),
),
- # Light
- # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy
- "dj": (
+ DeviceCategory.DJ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -127,11 +127,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness=DPCode.BRIGHT_VALUE_1,
),
),
- # Filament Light
- # Based on data from https://github.com/home-assistant/core/issues/106703
- # Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6
- # As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc
- "dsd": (
+ DeviceCategory.DSD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -139,9 +135,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness=DPCode.BRIGHT_VALUE,
),
),
- # Fan
- # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
- "fs": (
+ DeviceCategory.FS: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
name=None,
@@ -156,9 +150,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness=DPCode.BRIGHT_VALUE_1,
),
),
- # Ceiling Fan Light
- # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v
- "fsd": (
+ DeviceCategory.FSD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -173,9 +165,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
name=None,
),
),
- # Ambient Light
- # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g
- "fwd": (
+ DeviceCategory.FWD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -185,9 +175,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Motion Sensor Light
- # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy
- "gyd": (
+ DeviceCategory.GYD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -197,9 +185,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Wake Up Light II
- # Not documented
- "hxd": (
+ DeviceCategory.HXD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
translation_key="light",
@@ -208,9 +194,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness_min=DPCode.BRIGHTNESS_MIN_1,
),
),
- # Humidifier Light
- # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
- "jsq": (
+ DeviceCategory.JSQ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -219,46 +203,35 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA_HSV,
),
),
- # Switch
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
- "kg": (
+ DeviceCategory.KG: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_BACKLIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # Air Purifier
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
- "kj": (
+ DeviceCategory.KJ: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # Air conditioner
- # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
- "kt": (
+ DeviceCategory.KT: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # Undocumented tower fan
- # https://github.com/orgs/home-assistant/discussions/329
- "ks": (
+ DeviceCategory.KS: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # Unknown light product
- # Found as VECINO RGBW as provided by diagnostics
- # Not documented
- "mbd": (
+ DeviceCategory.MBD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -267,10 +240,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Unknown product with light capabilities
- # Fond in some diffusers, plugs and PIR flood lights
- # Not documented
- "qjdcz": (
+ DeviceCategory.QJDCZ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -279,18 +249,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
- "qn": (
+ DeviceCategory.QN: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="backlight",
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SP: (
TuyaLightEntityDescription(
key=DPCode.FLOODLIGHT_SWITCH,
brightness=DPCode.FLOODLIGHT_LIGHTNESS,
@@ -302,18 +268,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Gardening system
- # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
- "sz": (
+ DeviceCategory.SZ: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
brightness=DPCode.BRIGHT_VALUE,
translation_key="light",
),
),
- # Dimmer Switch
- # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
- "tgkg": (
+ DeviceCategory.TGKG: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED_1,
translation_key="indexed_light",
@@ -339,9 +301,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness_min=DPCode.BRIGHTNESS_MIN_3,
),
),
- # Dimmer
- # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4
- "tgq": (
+ DeviceCategory.TGQ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
translation_key="light",
@@ -362,9 +322,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness=DPCode.BRIGHT_VALUE_2,
),
),
- # Outdoor Flood Light
- # Not documented
- "tyd": (
+ DeviceCategory.TYD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -374,9 +332,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Solar Light
- # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
- "tyndj": (
+ DeviceCategory.TYNDJ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -386,9 +342,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
- # Ceiling Light
- # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r
- "xdd": (
+ DeviceCategory.XDD: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,
name=None,
@@ -402,9 +356,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
translation_key="night_light",
),
),
- # Remote Control
- # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov
- "ykq": (
+ DeviceCategory.YKQ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_CONTROLLER,
name=None,
@@ -417,19 +369,16 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
# Socket (duplicate of `kg`)
# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-LIGHTS["cz"] = LIGHTS["kg"]
+LIGHTS[DeviceCategory.CZ] = LIGHTS[DeviceCategory.KG]
# Power Socket (duplicate of `kg`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-LIGHTS["pc"] = LIGHTS["kg"]
+LIGHTS[DeviceCategory.PC] = LIGHTS[DeviceCategory.KG]
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-LIGHTS["dghsxj"] = LIGHTS["sp"]
+LIGHTS[DeviceCategory.DGHSXJ] = LIGHTS[DeviceCategory.SP]
# Dimmer (duplicate of `tgq`)
-# https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4
-LIGHTS["tdq"] = LIGHTS["tgq"]
+LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ]
@dataclass
@@ -461,24 +410,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya light dynamically through tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]):
"""Discover and add a discovered tuya light."""
entities: list[TuyaLightEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := LIGHTS.get(device.category):
entities.extend(
- TuyaLightEntity(device, hass_data.manager, description)
+ TuyaLightEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
@@ -531,7 +480,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if (
dpcode := get_dpcode(self.device, description.color_data)
- ) and self.get_dptype(dpcode) == DPType.JSON:
+ ) and self.get_dptype(dpcode, prefer_function=True) == DPType.JSON:
self._color_data_dpcode = dpcode
color_modes.add(ColorMode.HS)
if dpcode in self.device.function:
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
index 96ee50a38c9..cfc074a6a46 100644
--- a/homeassistant/components/tuya/manifest.json
+++ b/homeassistant/components/tuya/manifest.json
@@ -42,6 +42,6 @@
"documentation": "https://www.home-assistant.io/integrations/tuya",
"integration_type": "hub",
"iot_class": "cloud_push",
- "loggers": ["tuya_iot"],
- "requirements": ["tuya-device-sharing-sdk==0.2.1"]
+ "loggers": ["tuya_sharing"],
+ "requirements": ["tuya-device-sharing-sdk==0.2.4"]
}
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index 7fadaa0489b..1fb00a4de51 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -21,6 +21,7 @@ from .const import (
DOMAIN,
LOGGER,
TUYA_DISCOVERY_NEW,
+ DeviceCategory,
DPCode,
DPType,
)
@@ -28,13 +29,8 @@ from .entity import TuyaEntity
from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError
-# All descriptions can be found here. Mostly the Integer data types in the
-# default instructions set of each category end up being a number.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
- # Smart Kettle
- # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7
- "bh": (
+NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
+ DeviceCategory.BH: (
NumberEntityDescription(
key=DPCode.TEMP_SET,
translation_key="temperature",
@@ -65,9 +61,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # CO2 Detector
- # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
- "co2bj": (
+ DeviceCategory.BZYD: (
+ NumberEntityDescription(
+ key=DPCode.VOLUME_SET,
+ translation_key="volume",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.CO2BJ: (
NumberEntityDescription(
key=DPCode.ALARM_TIME,
translation_key="alarm_duration",
@@ -76,9 +77,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Pet Feeder
- # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
- "cwwsq": (
+ DeviceCategory.CWWSQ: (
NumberEntityDescription(
key=DPCode.MANUAL_FEED,
translation_key="feed",
@@ -88,27 +87,21 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
translation_key="voice_times",
),
),
- # Multi-functional Sensor
- # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
- "dgnbj": (
+ DeviceCategory.DGNBJ: (
NumberEntityDescription(
key=DPCode.ALARM_TIME,
translation_key="time",
entity_category=EntityCategory.CONFIG,
),
),
- # Fan
- # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
- "fs": (
+ DeviceCategory.FS: (
NumberEntityDescription(
key=DPCode.TEMP,
translation_key="temperature",
device_class=NumberDeviceClass.TEMPERATURE,
),
),
- # Human Presence Sensor
- # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs
- "hps": (
+ DeviceCategory.HPS: (
NumberEntityDescription(
key=DPCode.SENSITIVITY,
translation_key="sensitivity",
@@ -132,9 +125,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
device_class=NumberDeviceClass.DISTANCE,
),
),
- # Humidifier
- # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
- "jsq": (
+ DeviceCategory.JSQ: (
NumberEntityDescription(
key=DPCode.TEMP_SET,
translation_key="temperature",
@@ -146,9 +137,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
device_class=NumberDeviceClass.TEMPERATURE,
),
),
- # Coffee maker
- # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f
- "kfj": (
+ DeviceCategory.KFJ: (
NumberEntityDescription(
key=DPCode.WATER_SET,
translation_key="water_level",
@@ -171,9 +160,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Alarm Host
- # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk
- "mal": (
+ DeviceCategory.MAL: (
NumberEntityDescription(
key=DPCode.DELAY_SET,
# This setting is called "Arm Delay" in the official Tuya app
@@ -195,9 +182,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Sous Vide Cooker
- # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux
- "mzj": (
+ DeviceCategory.MZJ: (
NumberEntityDescription(
key=DPCode.COOK_TEMPERATURE,
translation_key="cook_temperature",
@@ -215,17 +200,27 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Robot Vacuum
- # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
- "sd": (
+ DeviceCategory.SWTZ: (
+ NumberEntityDescription(
+ key=DPCode.COOK_TEMPERATURE,
+ translation_key="cook_temperature",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ NumberEntityDescription(
+ key=DPCode.COOK_TEMPERATURE_2,
+ translation_key="indexed_cook_temperature",
+ translation_placeholders={"index": "2"},
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.SD: (
NumberEntityDescription(
key=DPCode.VOLUME_SET,
translation_key="volume",
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Water Timer
- "sfkzq": (
+ DeviceCategory.SFKZQ: (
# Controls the irrigation duration for the water valve
NumberEntityDescription(
key=DPCode.COUNTDOWN_1,
@@ -284,26 +279,21 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Siren Alarm
- # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
- "sgbj": (
+ DeviceCategory.SGBJ: (
NumberEntityDescription(
key=DPCode.ALARM_TIME,
translation_key="time",
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SP: (
NumberEntityDescription(
key=DPCode.BASIC_DEVICE_VOLUME,
translation_key="volume",
entity_category=EntityCategory.CONFIG,
),
),
- # Fingerbot
- "szjqr": (
+ DeviceCategory.SZJQR: (
NumberEntityDescription(
key=DPCode.ARM_DOWN_PERCENT,
translation_key="move_down",
@@ -322,9 +312,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Dimmer Switch
- # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
- "tgkg": (
+ DeviceCategory.TGKG: (
NumberEntityDescription(
key=DPCode.BRIGHTNESS_MIN_1,
translation_key="indexed_minimum_brightness",
@@ -362,9 +350,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Dimmer Switch
- # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
- "tgq": (
+ DeviceCategory.TGQ: (
NumberEntityDescription(
key=DPCode.BRIGHTNESS_MIN_1,
translation_key="indexed_minimum_brightness",
@@ -390,18 +376,27 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Thermostat
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
- "wk": (
+ DeviceCategory.WK: (
NumberEntityDescription(
key=DPCode.TEMP_CORRECTION,
translation_key="temp_correction",
entity_category=EntityCategory.CONFIG,
),
),
- # Tank Level Sensor
- # Note: Undocumented
- "ywcgq": (
+ DeviceCategory.XNYJCN: (
+ NumberEntityDescription(
+ key=DPCode.BACKUP_RESERVE,
+ translation_key="battery_backup_reserve",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ NumberEntityDescription(
+ key=DPCode.OUTPUT_POWER_LIMIT,
+ translation_key="inverter_output_power_limit",
+ device_class=NumberDeviceClass.POWER,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.YWCGQ: (
NumberEntityDescription(
key=DPCode.MAX_SET,
translation_key="alarm_maximum",
@@ -425,17 +420,14 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Vibration Sensor
- # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
- "zd": (
+ DeviceCategory.ZD: (
NumberEntityDescription(
key=DPCode.SENSITIVITY,
translation_key="sensitivity",
entity_category=EntityCategory.CONFIG,
),
),
- # Pool HeatPump
- "znrb": (
+ DeviceCategory.ZNRB: (
NumberEntityDescription(
key=DPCode.TEMP_SET,
translation_key="temperature",
@@ -445,8 +437,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
}
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-NUMBERS["dghsxj"] = NUMBERS["sp"]
+NUMBERS[DeviceCategory.DGHSXJ] = NUMBERS[DeviceCategory.SP]
async def async_setup_entry(
@@ -455,24 +446,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya number dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya number."""
entities: list[TuyaNumberEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := NUMBERS.get(device.category):
entities.extend(
- TuyaNumberEntity(device, hass_data.manager, description)
+ TuyaNumberEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py
index 4ad027d39ee..239aabd9bcc 100644
--- a/homeassistant/components/tuya/scene.py
+++ b/homeassistant/components/tuya/scene.py
@@ -21,9 +21,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya scenes."""
- hass_data = entry.runtime_data
- scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes)
- async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes)
+ manager = entry.runtime_data.manager
+ scenes = await hass.async_add_executor_job(manager.query_scenes)
+ async_add_entities(TuyaSceneEntity(manager, scene) for scene in scenes)
class TuyaSceneEntity(Scene):
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index 296a5e3cc2c..6a4d8d7b488 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -11,16 +11,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
# All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
- # Curtain
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc
- "cl": (
+SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = {
+ DeviceCategory.CL: (
SelectEntityDescription(
key=DPCode.CONTROL_BACK_MODE,
entity_category=EntityCategory.CONFIG,
@@ -32,18 +29,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="curtain_mode",
),
),
- # CO2 Detector
- # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
- "co2bj": (
+ DeviceCategory.CO2BJ: (
SelectEntityDescription(
key=DPCode.ALARM_VOLUME,
translation_key="volume",
entity_category=EntityCategory.CONFIG,
),
),
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
- "cs": (
+ DeviceCategory.CS: (
SelectEntityDescription(
key=DPCode.COUNTDOWN_SET,
entity_category=EntityCategory.CONFIG,
@@ -55,49 +48,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Odor Eliminator-Pro
- # Undocumented, see https://github.com/orgs/home-assistant/discussions/79
- "cwjwq": (
+ DeviceCategory.CWJWQ: (
SelectEntityDescription(
key=DPCode.WORK_MODE,
entity_category=EntityCategory.CONFIG,
translation_key="odor_elimination_mode",
),
),
- # Multi-functional Sensor
- # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
- "dgnbj": (
+ DeviceCategory.DGNBJ: (
SelectEntityDescription(
key=DPCode.ALARM_VOLUME,
translation_key="volume",
entity_category=EntityCategory.CONFIG,
),
),
- # Electric Blanket
- # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p
- "dr": (
+ DeviceCategory.DR: (
SelectEntityDescription(
key=DPCode.LEVEL,
- name="Level",
icon="mdi:thermometer-lines",
translation_key="blanket_level",
),
SelectEntityDescription(
key=DPCode.LEVEL_1,
- name="Side A Level",
icon="mdi:thermometer-lines",
- translation_key="blanket_level",
+ translation_key="indexed_blanket_level",
+ translation_placeholders={"index": "1"},
),
SelectEntityDescription(
key=DPCode.LEVEL_2,
- name="Side B Level",
icon="mdi:thermometer-lines",
- translation_key="blanket_level",
+ translation_key="indexed_blanket_level",
+ translation_placeholders={"index": "2"},
),
),
- # Fan
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge
- "fs": (
+ DeviceCategory.FS: (
SelectEntityDescription(
key=DPCode.FAN_VERTICAL,
entity_category=EntityCategory.CONFIG,
@@ -119,9 +103,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="countdown",
),
),
- # Humidifier
- # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
- "jsq": (
+ DeviceCategory.JSQ: (
SelectEntityDescription(
key=DPCode.SPRAY_MODE,
entity_category=EntityCategory.CONFIG,
@@ -148,9 +130,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="countdown",
),
),
- # Coffee maker
- # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f
- "kfj": (
+ DeviceCategory.KFJ: (
SelectEntityDescription(
key=DPCode.CUP_NUMBER,
translation_key="cups",
@@ -170,9 +150,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="mode",
),
),
- # Switch
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
- "kg": (
+ DeviceCategory.KG: (
SelectEntityDescription(
key=DPCode.RELAY_STATUS,
entity_category=EntityCategory.CONFIG,
@@ -184,9 +162,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="light_mode",
),
),
- # Air Purifier
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
- "kj": (
+ DeviceCategory.KJ: (
SelectEntityDescription(
key=DPCode.COUNTDOWN,
entity_category=EntityCategory.CONFIG,
@@ -198,17 +174,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="countdown",
),
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
- "qn": (
+ DeviceCategory.QN: (
SelectEntityDescription(
key=DPCode.LEVEL,
translation_key="temperature_level",
),
),
- # Robot Vacuum
- # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
- "sd": (
+ DeviceCategory.SD: (
SelectEntityDescription(
key=DPCode.CISTERN,
entity_category=EntityCategory.CONFIG,
@@ -225,8 +197,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="vacuum_mode",
),
),
- # Smart Water Timer
- "sfkzq": (
+ DeviceCategory.SFKZQ: (
# Irrigation will not be run within this set delay period
SelectEntityDescription(
key=DPCode.WEATHER_DELAY,
@@ -234,9 +205,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Siren Alarm
- # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
- "sgbj": (
+ DeviceCategory.SGBJ: (
SelectEntityDescription(
key=DPCode.ALARM_VOLUME,
translation_key="volume",
@@ -248,9 +217,19 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SJZ: (
+ SelectEntityDescription(
+ key=DPCode.LEVEL,
+ translation_key="desk_level",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SelectEntityDescription(
+ key=DPCode.UP_DOWN,
+ translation_key="desk_up_down",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.SP: (
SelectEntityDescription(
key=DPCode.IPC_WORK_MODE,
entity_category=EntityCategory.CONFIG,
@@ -282,17 +261,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="motion_sensitivity",
),
),
- # Fingerbot
- "szjqr": (
+ DeviceCategory.SZJQR: (
SelectEntityDescription(
key=DPCode.MODE,
entity_category=EntityCategory.CONFIG,
translation_key="fingerbot_mode",
),
),
- # IoT Switch?
- # Note: Undocumented
- "tdq": (
+ DeviceCategory.TDQ: (
SelectEntityDescription(
key=DPCode.RELAY_STATUS,
entity_category=EntityCategory.CONFIG,
@@ -304,9 +280,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="light_mode",
),
),
- # Dimmer Switch
- # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
- "tgkg": (
+ DeviceCategory.TGKG: (
SelectEntityDescription(
key=DPCode.RELAY_STATUS,
entity_category=EntityCategory.CONFIG,
@@ -336,9 +310,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_placeholders={"index": "3"},
),
),
- # Dimmer
- # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4
- "tgq": (
+ DeviceCategory.TGQ: (
SelectEntityDescription(
key=DPCode.LED_TYPE_1,
entity_category=EntityCategory.CONFIG,
@@ -352,19 +324,23 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_placeholders={"index": "2"},
),
),
+ DeviceCategory.XNYJCN: (
+ SelectEntityDescription(
+ key=DPCode.WORK_MODE,
+ translation_key="inverter_work_mode",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
# Socket (duplicate of `kg`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-SELECTS["cz"] = SELECTS["kg"]
+SELECTS[DeviceCategory.CZ] = SELECTS[DeviceCategory.KG]
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-SELECTS["dghsxj"] = SELECTS["sp"]
+SELECTS[DeviceCategory.DGHSXJ] = SELECTS[DeviceCategory.SP]
# Power Socket (duplicate of `kg`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-SELECTS["pc"] = SELECTS["kg"]
+SELECTS[DeviceCategory.PC] = SELECTS[DeviceCategory.KG]
async def async_setup_entry(
@@ -373,24 +349,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya select dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya select."""
entities: list[TuyaSelectEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := SELECTS.get(device.category):
entities.extend(
- TuyaSelectEntity(device, hass_data.manager, description)
+ TuyaSelectEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index fe7db2b28b9..0ad28cbc096 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -37,6 +37,7 @@ from .const import (
DOMAIN,
LOGGER,
TUYA_DISCOVERY_NEW,
+ DeviceCategory,
DPCode,
DPType,
UnitOfMeasurement,
@@ -115,11 +116,8 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = (
# All descriptions can be found here. Mostly the Integer data types in the
# default status set of each category (that don't have a set instruction)
# end up being a sensor.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
- # Single Phase power meter
- # Note: Undocumented
- "aqcz": (
+SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
+ DeviceCategory.AQCZ: (
TuyaSensorEntityDescription(
key=DPCode.CUR_CURRENT,
translation_key="current",
@@ -144,9 +142,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
- # Smart Kettle
- # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7
- "bh": (
+ DeviceCategory.BH: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="current_temperature",
@@ -164,18 +160,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="status",
),
),
- # Curtain
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre
- "cl": (
+ DeviceCategory.CL: (
TuyaSensorEntityDescription(
key=DPCode.TIME_TOTAL,
translation_key="last_operation_duration",
entity_category=EntityCategory.DIAGNOSTIC,
),
),
- # CO2 Detector
- # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
- "co2bj": (
+ DeviceCategory.CO2BJ: (
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_VALUE,
translation_key="humidity",
@@ -213,11 +205,15 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.PM10,
+ translation_key="pm10",
+ device_class=SensorDeviceClass.PM10,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
*BATTERY_SENSORS,
),
- # CO Detector
- # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
- "cobj": (
+ DeviceCategory.COBJ: (
TuyaSensorEntityDescription(
key=DPCode.CO_VALUE,
translation_key="carbon_monoxide",
@@ -227,9 +223,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e
- "cs": (
+ DeviceCategory.CS: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_INDOOR,
translation_key="temperature",
@@ -243,27 +237,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Smart Odor Eliminator-Pro
- # Undocumented, see https://github.com/orgs/home-assistant/discussions/79
- "cwjwq": (
+ DeviceCategory.CWJWQ: (
TuyaSensorEntityDescription(
key=DPCode.WORK_STATE_E,
translation_key="odor_elimination_status",
),
*BATTERY_SENSORS,
),
- # Smart Pet Feeder
- # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
- "cwwsq": (
+ DeviceCategory.CWWSQ: (
TuyaSensorEntityDescription(
key=DPCode.FEED_REPORT,
translation_key="last_amount",
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Pet Fountain
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln
- "cwysj": (
+ DeviceCategory.CWYSJ: (
TuyaSensorEntityDescription(
key=DPCode.UV_RUNTIME,
translation_key="uv_runtime",
@@ -294,9 +282,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
key=DPCode.WATER_LEVEL, translation_key="water_level_state"
),
),
- # Multi-functional Sensor
- # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
- "dgnbj": (
+ DeviceCategory.DGNBJ: (
TuyaSensorEntityDescription(
key=DPCode.GAS_SENSOR_VALUE,
translation_key="gas",
@@ -370,9 +356,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Circuit Breaker
- # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8
- "dlq": (
+ DeviceCategory.DLQ: (
TuyaSensorEntityDescription(
key=DPCode.TOTAL_FORWARD_ENERGY,
translation_key="total_energy",
@@ -380,7 +364,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.TOTAL_INCREASING,
),
TuyaSensorEntityDescription(
- key=DPCode.CUR_NEUTRAL,
+ key=DPCode.ADD_ELE,
+ translation_key="total_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.FORWARD_ENERGY_TOTAL,
+ translation_key="total_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.REVERSE_ENERGY_TOTAL,
translation_key="total_production",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -497,9 +493,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
- # Fan
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54
- "fs": (
+ DeviceCategory.FS: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -507,12 +501,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Irrigator
- # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k
- "ggq": BATTERY_SENSORS,
- # Air Quality Monitor
- # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv
- "hjjcy": (
+ DeviceCategory.GGQ: BATTERY_SENSORS,
+ DeviceCategory.HJJCY: (
TuyaSensorEntityDescription(
key=DPCode.AIR_QUALITY_INDEX,
translation_key="air_quality_index",
@@ -563,9 +553,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Formaldehyde Detector
- # Note: Not documented
- "jqbj": (
+ DeviceCategory.JQBJ: (
TuyaSensorEntityDescription(
key=DPCode.CO2_VALUE,
translation_key="carbon_dioxide",
@@ -605,9 +593,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Humidifier
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3
- "jsq": (
+ DeviceCategory.JSQ: (
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_CURRENT,
translation_key="humidity",
@@ -632,9 +618,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_category=EntityCategory.DIAGNOSTIC,
),
),
- # Methane Detector
- # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm
- "jwbj": (
+ DeviceCategory.JWBJ: (
TuyaSensorEntityDescription(
key=DPCode.CH4_SENSOR_VALUE,
translation_key="methane",
@@ -642,9 +626,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Switch
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
- "kg": (
+ DeviceCategory.KG: (
TuyaSensorEntityDescription(
key=DPCode.CUR_CURRENT,
translation_key="current",
@@ -668,10 +650,20 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.ADD_ELE,
+ translation_key="total_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.PRO_ADD_ELE,
+ translation_key="total_production",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
),
- # Air Purifier
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81
- "kj": (
+ DeviceCategory.KJ: (
TuyaSensorEntityDescription(
key=DPCode.FILTER,
translation_key="filter_utilization",
@@ -726,9 +718,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="air_quality",
),
),
- # Luminance Sensor
- # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
- "ldcg": (
+ DeviceCategory.LDCG: (
TuyaSensorEntityDescription(
key=DPCode.BRIGHT_STATE,
translation_key="luminosity",
@@ -760,15 +750,17 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Door and Window Controller
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9
- "mc": BATTERY_SENSORS,
- # Door Window Sensor
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m
- "mcs": BATTERY_SENSORS,
- # Sous Vide Cooker
- # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux
- "mzj": (
+ DeviceCategory.MC: BATTERY_SENSORS,
+ DeviceCategory.MCS: BATTERY_SENSORS,
+ DeviceCategory.MSP: (
+ TuyaSensorEntityDescription(
+ key=DPCode.CAT_WEIGHT,
+ translation_key="cat_weight",
+ device_class=SensorDeviceClass.WEIGHT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ ),
+ DeviceCategory.MZJ: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="current_temperature",
@@ -785,12 +777,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
native_unit_of_measurement=UnitOfTime.MINUTES,
),
),
- # PIR Detector
- # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80
- "pir": BATTERY_SENSORS,
- # PM2.5 Sensor
- # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu
- "pm2.5": (
+ DeviceCategory.PIR: BATTERY_SENSORS,
+ DeviceCategory.PM2_5: (
TuyaSensorEntityDescription(
key=DPCode.PM25_VALUE,
translation_key="pm25",
@@ -844,9 +832,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
- "qn": (
+ DeviceCategory.QN: (
TuyaSensorEntityDescription(
key=DPCode.WORK_POWER,
translation_key="power",
@@ -854,9 +840,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Temperature and Humidity Sensor with External Probe
- # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
- "qxj": (
+ DeviceCategory.QXJ: (
TuyaSensorEntityDescription(
key=DPCode.VA_TEMPERATURE,
translation_key="temperature",
@@ -949,7 +933,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
TuyaSensorEntityDescription(
key=DPCode.WINDSPEED_AVG,
- translation_key="wind_speed",
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
@@ -977,11 +960,33 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
),
+ TuyaSensorEntityDescription(
+ key=DPCode.DEW_POINT_TEMP,
+ translation_key="dew_point_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.FEELLIKE_TEMP,
+ translation_key="feels_like_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.HEAT_INDEX,
+ translation_key="heat_index_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.WINDCHILL_INDEX,
+ translation_key="wind_chill_index_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
*BATTERY_SENSORS,
),
- # Gas Detector
- # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw
- "rqbj": (
+ DeviceCategory.RQBJ: (
TuyaSensorEntityDescription(
key=DPCode.GAS_SENSOR_VALUE,
name=None,
@@ -990,9 +995,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Robot Vacuum
- # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
- "sd": (
+ DeviceCategory.SD: (
TuyaSensorEntityDescription(
key=DPCode.CLEAN_AREA,
translation_key="cleaning_area",
@@ -1046,8 +1049,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Smart Water Timer
- "sfkzq": (
+ DeviceCategory.SFKZQ: (
# Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug)
TuyaSensorEntityDescription(
key=DPCode.TIME_USE,
@@ -1057,15 +1059,10 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Water Detector
- # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
- "sj": BATTERY_SENSORS,
- # Emergency Button
- # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy
- "sos": BATTERY_SENSORS,
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SGBJ: BATTERY_SENSORS,
+ DeviceCategory.SJ: BATTERY_SENSORS,
+ DeviceCategory.SOS: BATTERY_SENSORS,
+ DeviceCategory.SP: (
TuyaSensorEntityDescription(
key=DPCode.SENSOR_TEMPERATURE,
translation_key="temperature",
@@ -1086,9 +1083,23 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Smart Gardening system
- # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
- "sz": (
+ DeviceCategory.SWTZ: (
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT_2,
+ translation_key="indexed_temperature",
+ translation_placeholders={"index": "2"},
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ *BATTERY_SENSORS,
+ ),
+ DeviceCategory.SZ: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -1102,11 +1113,22 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Fingerbot
- "szjqr": BATTERY_SENSORS,
- # IoT Switch
- # Note: Undocumented
- "tdq": (
+ DeviceCategory.SZJCY: (
+ TuyaSensorEntityDescription(
+ key=DPCode.TDS_IN,
+ translation_key="total_dissolved_solids",
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ *BATTERY_SENSORS,
+ ),
+ DeviceCategory.SZJQR: BATTERY_SENSORS,
+ DeviceCategory.TDQ: (
TuyaSensorEntityDescription(
key=DPCode.CUR_CURRENT,
translation_key="current",
@@ -1130,6 +1152,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.ADD_ELE,
+ translation_key="total_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
TuyaSensorEntityDescription(
key=DPCode.VA_TEMPERATURE,
translation_key="temperature",
@@ -1162,12 +1190,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Solar Light
- # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
- "tyndj": BATTERY_SENSORS,
- # Volatile Organic Compound Sensor
- # Note: Undocumented in cloud API docs, based on test device
- "voc": (
+ DeviceCategory.TYNDJ: BATTERY_SENSORS,
+ DeviceCategory.VOC: (
TuyaSensorEntityDescription(
key=DPCode.CO2_VALUE,
translation_key="carbon_dioxide",
@@ -1207,13 +1231,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Thermostat
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
- "wk": (*BATTERY_SENSORS,),
- # Two-way temperature and humidity switch
- # "MOES Temperature and Humidity Smart Switch Module MS-103"
- # Documentation not found
- "wkcz": (
+ DeviceCategory.WK: (*BATTERY_SENSORS,),
+ DeviceCategory.WKCZ: (
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_VALUE,
translation_key="humidity",
@@ -1250,12 +1269,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
- # Thermostatic Radiator Valve
- # Not documented
- "wkf": BATTERY_SENSORS,
- # eMylo Smart WiFi IR Remote
- # Air Conditioner Mate (Smart IR Socket)
- "wnykq": (
+ DeviceCategory.WKF: BATTERY_SENSORS,
+ DeviceCategory.WNYKQ: (
TuyaSensorEntityDescription(
key=DPCode.VA_TEMPERATURE,
translation_key="temperature",
@@ -1295,9 +1310,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
- # Temperature and Humidity Sensor
- # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
- "wsdcg": (
+ DeviceCategory.WSDCG: (
TuyaSensorEntityDescription(
key=DPCode.VA_TEMPERATURE,
translation_key="temperature",
@@ -1330,11 +1343,79 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Wireless Switch
- # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp
- "wxkg": BATTERY_SENSORS, # Pressure Sensor
- # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
- "ylcg": (
+ DeviceCategory.WXKG: BATTERY_SENSORS,
+ DeviceCategory.XNYJCN: (
+ TuyaSensorEntityDescription(
+ key=DPCode.CURRENT_SOC,
+ translation_key="battery_soc",
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.PV_POWER_TOTAL,
+ translation_key="total_pv_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.PV_POWER_CHANNEL_1,
+ translation_key="pv_channel_power",
+ translation_placeholders={"index": "1"},
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.PV_POWER_CHANNEL_2,
+ translation_key="pv_channel_power",
+ translation_placeholders={"index": "2"},
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.BATTERY_POWER,
+ translation_key="battery_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.INVERTER_OUTPUT_POWER,
+ translation_key="inverter_output_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUMULATIVE_ENERGY_GENERATED_PV,
+ translation_key="lifetime_pv_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUMULATIVE_ENERGY_OUTPUT_INV,
+ translation_key="lifetime_inverter_output_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUMULATIVE_ENERGY_DISCHARGED,
+ translation_key="lifetime_battery_discharge_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUMULATIVE_ENERGY_CHARGED,
+ translation_key="lifetime_battery_charge_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUML_E_EXPORT_OFFGRID1,
+ translation_key="lifetime_offgrid_port_energy",
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ ),
+ DeviceCategory.YLCG: (
TuyaSensorEntityDescription(
key=DPCode.PRESSURE_VALUE,
name=None,
@@ -1343,9 +1424,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Smoke Detector
- # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
- "ywbj": (
+ DeviceCategory.YWBJ: (
TuyaSensorEntityDescription(
key=DPCode.SMOKE_SENSOR_VALUE,
translation_key="smoke_amount",
@@ -1354,9 +1433,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
- # Tank Level Sensor
- # Note: Undocumented
- "ywcgq": (
+ DeviceCategory.YWCGQ: (
TuyaSensorEntityDescription(
key=DPCode.LIQUID_STATE,
translation_key="liquid_state",
@@ -1373,12 +1450,8 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Vibration Sensor
- # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
- "zd": BATTERY_SENSORS,
- # Smart Electricity Meter
- # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7
- "zndb": (
+ DeviceCategory.ZD: BATTERY_SENSORS,
+ DeviceCategory.ZNDB: (
TuyaSensorEntityDescription(
key=DPCode.FORWARD_ENERGY_TOTAL,
translation_key="total_energy",
@@ -1391,6 +1464,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.POWER_TOTAL,
+ translation_key="total_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
TuyaSensorEntityDescription(
key=DPCode.TOTAL_POWER,
translation_key="total_power",
@@ -1488,8 +1567,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
subkey="voltage",
),
),
- # VESKA-micro inverter
- "znnbq": (
+ DeviceCategory.ZNNBQ: (
TuyaSensorEntityDescription(
key=DPCode.REVERSE_ENERGY_TOTAL,
translation_key="total_energy",
@@ -1512,8 +1590,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Pool HeatPump
- "znrb": (
+ DeviceCategory.ZNRB: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -1521,8 +1598,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
- # Soil sensor (Plant monitor)
- "zwjcy": (
+ DeviceCategory.ZWJCY: (
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -1540,16 +1616,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
}
# Socket (duplicate of `kg`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-SENSORS["cz"] = SENSORS["kg"]
+SENSORS[DeviceCategory.CZ] = SENSORS[DeviceCategory.KG]
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-SENSORS["dghsxj"] = SENSORS["sp"]
+SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
# Power Socket (duplicate of `kg`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-SENSORS["pc"] = SENSORS["kg"]
+SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
async def async_setup_entry(
@@ -1558,24 +1631,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya sensor dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya sensor."""
entities: list[TuyaSensorEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := SENSORS.get(device.category):
entities.extend(
- TuyaSensorEntity(device, hass_data.manager, description)
+ TuyaSensorEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 8003dc2cf21..8c29684ba9f 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -17,37 +17,27 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
-# All descriptions can be found here:
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
- # CO2 Detector
- # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
- "co2bj": (
+SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
+ DeviceCategory.CO2BJ: (
SirenEntityDescription(
key=DPCode.ALARM_SWITCH,
entity_category=EntityCategory.CONFIG,
),
),
- # Multi-functional Sensor
- # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
- "dgnbj": (
+ DeviceCategory.DGNBJ: (
SirenEntityDescription(
key=DPCode.ALARM_SWITCH,
),
),
- # Siren Alarm
- # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
- "sgbj": (
+ DeviceCategory.SGBJ: (
SirenEntityDescription(
key=DPCode.ALARM_SWITCH,
),
),
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SP: (
SirenEntityDescription(
key=DPCode.SIREN_SWITCH,
),
@@ -55,8 +45,7 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
}
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-SIRENS["dghsxj"] = SIRENS["sp"]
+SIRENS[DeviceCategory.DGHSXJ] = SIRENS[DeviceCategory.SP]
async def async_setup_entry(
@@ -65,24 +54,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya siren dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya siren."""
entities: list[TuyaSirenEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category):
entities.extend(
- TuyaSirenEntity(device, hass_data.manager, description)
+ TuyaSirenEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json
index fa15e34694c..f7eb9f43be4 100644
--- a/homeassistant/components/tuya/strings.json
+++ b/homeassistant/components/tuya/strings.json
@@ -139,6 +139,9 @@
"temperature": {
"name": "[%key:component::sensor::entity_component::temperature::name%]"
},
+ "indexed_temperature": {
+ "name": "Temperature {index}"
+ },
"time": {
"name": "Time"
},
@@ -167,7 +170,7 @@
"name": "Far detection"
},
"target_dis_closest": {
- "name": "Clostest target distance"
+ "name": "Closest target distance"
},
"water_level": {
"name": "Water level"
@@ -176,10 +179,13 @@
"name": "Powder"
},
"cook_temperature": {
- "name": "Cook temperature"
+ "name": "Cooking temperature"
+ },
+ "indexed_cook_temperature": {
+ "name": "Cooking temperature {index}"
},
"cook_time": {
- "name": "Cook time"
+ "name": "Cooking time"
},
"cloud_recipe": {
"name": "Cloud recipe"
@@ -226,11 +232,17 @@
"alarm_minimum": {
"name": "Alarm minimum"
},
+ "battery_backup_reserve": {
+ "name": "Battery backup reserve"
+ },
"installation_height": {
"name": "Installation height"
},
"maximum_liquid_depth": {
"name": "Maximum liquid depth"
+ },
+ "inverter_output_power_limit": {
+ "name": "Inverter output power limit"
}
},
"select": {
@@ -477,6 +489,7 @@
}
},
"blanket_level": {
+ "name": "Level",
"state": {
"level_1": "[%key:common::state::low%]",
"level_2": "Level 2",
@@ -490,12 +503,52 @@
"level_10": "[%key:common::state::high%]"
}
},
+ "indexed_blanket_level": {
+ "name": "Level {index}",
+ "state": {
+ "level_1": "[%key:common::state::low%]",
+ "level_2": "[%key:component::tuya::entity::select::blanket_level::state::level_2%]",
+ "level_3": "[%key:component::tuya::entity::select::blanket_level::state::level_3%]",
+ "level_4": "[%key:component::tuya::entity::select::blanket_level::state::level_4%]",
+ "level_5": "[%key:component::tuya::entity::select::blanket_level::state::level_5%]",
+ "level_6": "[%key:component::tuya::entity::select::blanket_level::state::level_6%]",
+ "level_7": "[%key:component::tuya::entity::select::blanket_level::state::level_7%]",
+ "level_8": "[%key:component::tuya::entity::select::blanket_level::state::level_8%]",
+ "level_9": "[%key:component::tuya::entity::select::blanket_level::state::level_9%]",
+ "level_10": "[%key:common::state::high%]"
+ }
+ },
"odor_elimination_mode": {
"name": "Odor elimination mode",
"state": {
"smart": "Smart",
"interim": "Interim"
}
+ },
+ "desk_level": {
+ "name": "Level",
+ "state": {
+ "level_1": "Level 1",
+ "level_2": "Level 2",
+ "level_3": "Level 3",
+ "level_4": "Level 4"
+ }
+ },
+ "desk_up_down": {
+ "name": "Up/Down",
+ "state": {
+ "up": "Up",
+ "down": "Down",
+ "stop": "Stop"
+ }
+ },
+ "inverter_work_mode": {
+ "name": "Inverter work mode",
+ "state": {
+ "self_powered": "Self-powered",
+ "time_of_use": "Time of use",
+ "manual": "Manual mode"
+ }
}
},
"sensor": {
@@ -568,6 +621,36 @@
"battery_state": {
"name": "Battery state"
},
+ "battery_soc": {
+ "name": "Battery SOC"
+ },
+ "battery_power": {
+ "name": "Battery power"
+ },
+ "total_pv_power": {
+ "name": "Total PV power"
+ },
+ "pv_channel_power": {
+ "name": "PV channel {index} power"
+ },
+ "inverter_output_power": {
+ "name": "Inverter output power"
+ },
+ "lifetime_pv_energy": {
+ "name": "Lifetime PV energy"
+ },
+ "lifetime_inverter_output_energy": {
+ "name": "Lifetime inverter output energy"
+ },
+ "lifetime_battery_discharge_energy": {
+ "name": "Lifetime battery discharge energy"
+ },
+ "lifetime_battery_charge_energy": {
+ "name": "Lifetime battery charge energy"
+ },
+ "lifetime_offgrid_port_energy": {
+ "name": "Lifetime off-grid port energy"
+ },
"gas": {
"name": "Gas"
},
@@ -586,6 +669,9 @@
"status": {
"name": "Status"
},
+ "depth": {
+ "name": "Depth"
+ },
"last_amount": {
"name": "Last amount"
},
@@ -598,6 +684,9 @@
"total_energy": {
"name": "Total energy"
},
+ "total_power": {
+ "name": "Total power"
+ },
"total_production": {
"name": "Total production"
},
@@ -733,6 +822,9 @@
"water_time": {
"name": "Water usage duration"
},
+ "cat_weight": {
+ "name": "Cat weight"
+ },
"odor_elimination_status": {
"name": "Status",
"state": {
@@ -758,6 +850,21 @@
},
"supply_frequency": {
"name": "Supply frequency"
+ },
+ "total_dissolved_solids": {
+ "name": "Total dissolved solids"
+ },
+ "dew_point_temperature": {
+ "name": "Dew point"
+ },
+ "feels_like_temperature": {
+ "name": "Feels like"
+ },
+ "heat_index_temperature": {
+ "name": "Heat index"
+ },
+ "wind_chill_index_temperature": {
+ "name": "Wind chill index"
}
},
"switch": {
@@ -916,9 +1023,21 @@
},
"frost_protection": {
"name": "Frost protection"
+ },
+ "output_power_limit": {
+ "name": "Output power limit"
+ },
+ "music": {
+ "name": "Music"
+ },
+ "snooze": {
+ "name": "Snooze"
}
},
"valve": {
+ "valve": {
+ "name": "Valve"
+ },
"indexed_valve": {
"name": "Valve {index}"
}
@@ -928,5 +1047,11 @@
"action_dpcode_not_found": {
"message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})."
}
+ },
+ "issues": {
+ "deprecated_entity_new_valve": {
+ "title": "{name} is deprecated",
+ "description": "The Tuya entity `{entity}` is deprecated, replaced by a new valve entity.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
+ }
}
}
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index b9edc82ad71..a12562b455f 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -2,31 +2,46 @@
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode
+from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
+
+@dataclass(frozen=True, kw_only=True)
+class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription):
+ """Describes Tuya deprecated switch entity."""
+
+ deprecated: str
+ breaks_in_ha_version: str
+
+
# All descriptions can be found here. Mostly the Boolean data types in the
# default instruction set of each category end up being a Switch.
# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
- # Smart Kettle
- # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7
- "bh": (
+SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = {
+ DeviceCategory.BH: (
SwitchEntityDescription(
key=DPCode.START,
translation_key="start",
@@ -37,9 +52,31 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Curtain
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc
- "cl": (
+ DeviceCategory.BZYD: (
+ SwitchEntityDescription(
+ key=DPCode.SWITCH,
+ name=None,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.CHILD_LOCK,
+ translation_key="child_lock",
+ icon="mdi:account-lock",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.SWITCH_MUSIC,
+ translation_key="music",
+ icon="mdi:music",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.SNOOZE,
+ translation_key="snooze",
+ icon="mdi:alarm-snooze",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.CL: (
SwitchEntityDescription(
key=DPCode.CONTROL_BACK,
translation_key="reverse",
@@ -51,9 +88,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # EasyBaby
- # Undocumented, might have a wider use
- "cn": (
+ DeviceCategory.CN: (
SwitchEntityDescription(
key=DPCode.DISINFECTION,
translation_key="disinfection",
@@ -63,9 +98,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="water",
),
),
- # Dehumidifier
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e
- "cs": (
+ DeviceCategory.CS: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="ionizer",
@@ -85,26 +118,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Odor Eliminator-Pro
- # Undocumented, see https://github.com/orgs/home-assistant/discussions/79
- "cwjwq": (
+ DeviceCategory.CWJWQ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
),
),
- # Smart Pet Feeder
- # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
- "cwwsq": (
+ DeviceCategory.CWWSQ: (
SwitchEntityDescription(
key=DPCode.SLOW_FEED,
translation_key="slow_feed",
entity_category=EntityCategory.CONFIG,
),
),
- # Pet Fountain
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5
- "cwysj": (
+ DeviceCategory.CWYSJ: (
SwitchEntityDescription(
key=DPCode.FILTER_RESET,
translation_key="filter_reset",
@@ -130,9 +157,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Light
- # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3
- "dj": (
+ DeviceCategory.DJ: (
# There are sockets available with an RGB light
# that advertise as `dj`, but provide an additional
# switch to control the plug.
@@ -141,8 +166,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="plug",
),
),
- # Circuit Breaker
- "dlq": (
+ DeviceCategory.DLQ: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
@@ -153,9 +177,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="switch",
),
),
- # Electric Blanket
- # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p
- "dr": (
+ DeviceCategory.DR: (
SwitchEntityDescription(
key=DPCode.SWITCH,
name="Power",
@@ -193,9 +215,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
device_class=SwitchDeviceClass.SWITCH,
),
),
- # Fan
- # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
- "fs": (
+ DeviceCategory.FS: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="anion",
@@ -227,18 +247,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Ceiling Fan Light
- # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v
- "fsd": (
+ DeviceCategory.FSD: (
SwitchEntityDescription(
key=DPCode.FAN_BEEP,
translation_key="sound",
entity_category=EntityCategory.CONFIG,
),
),
- # Irrigator
- # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k
- "ggq": (
+ DeviceCategory.GGQ: (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_switch",
@@ -280,9 +296,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_placeholders={"index": "8"},
),
),
- # Wake Up Light II
- # Not documented
- "hxd": (
+ DeviceCategory.HXD: (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="radio",
@@ -316,9 +330,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="sleep_aid",
),
),
- # Humidifier
- # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
- "jsq": (
+ DeviceCategory.JSQ: (
SwitchEntityDescription(
key=DPCode.SWITCH_SOUND,
translation_key="voice",
@@ -335,9 +347,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Switch
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
- "kg": (
+ DeviceCategory.KG: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
@@ -427,9 +437,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
device_class=SwitchDeviceClass.OUTLET,
),
),
- # Air Purifier
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
- "kj": (
+ DeviceCategory.KJ: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="ionizer",
@@ -460,9 +468,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Air conditioner
- # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
- "kt": (
+ DeviceCategory.KT: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="ionizer",
@@ -474,17 +480,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Undocumented tower fan
- # https://github.com/orgs/home-assistant/discussions/329
- "ks": (
+ DeviceCategory.KS: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="ionizer",
),
),
- # Alarm Host
- # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk
- "mal": (
+ DeviceCategory.MAL: (
SwitchEntityDescription(
key=DPCode.SWITCH_ALARM_SOUND,
# This switch is called "Arm Beep" in the official Tuya app
@@ -498,9 +500,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Sous Vide Cooker
- # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux
- "mzj": (
+ DeviceCategory.MZJ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
@@ -512,9 +512,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Power Socket
- # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
- "pc": (
+ DeviceCategory.PC: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
@@ -592,26 +590,19 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
device_class=SwitchDeviceClass.OUTLET,
),
),
- # AC charging
- # Not documented
- "qccdz": (
+ DeviceCategory.QCCDZ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
),
),
- # Unknown product with switch capabilities
- # Fond in some diffusers, plugs and PIR flood lights
- # Not documented
- "qjdcz": (
+ DeviceCategory.QJDCZ: (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="switch",
),
),
- # Heater
- # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
- "qn": (
+ DeviceCategory.QN: (
SwitchEntityDescription(
key=DPCode.ANION,
translation_key="ionizer",
@@ -623,18 +614,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe
- # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
- "qxj": (
+ DeviceCategory.QXJ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
device_class=SwitchDeviceClass.OUTLET,
),
),
- # Robot Vacuum
- # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
- "sd": (
+ DeviceCategory.SD: (
SwitchEntityDescription(
key=DPCode.SWITCH_DISTURB,
translation_key="do_not_disturb",
@@ -646,25 +633,29 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Water Timer
- "sfkzq": (
- SwitchEntityDescription(
+ DeviceCategory.SFKZQ: (
+ TuyaDeprecatedSwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
+ deprecated="deprecated_entity_new_valve",
+ breaks_in_ha_version="2026.4.0",
),
),
- # Siren Alarm
- # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
- "sgbj": (
+ DeviceCategory.SGBJ: (
SwitchEntityDescription(
key=DPCode.MUFFLING,
translation_key="mute",
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Camera
- # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
- "sp": (
+ DeviceCategory.SJZ: (
+ SwitchEntityDescription(
+ key=DPCode.CHILD_LOCK,
+ translation_key="child_lock",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.SP: (
SwitchEntityDescription(
key=DPCode.WIRELESS_BATTERYLOCK,
translation_key="battery_lock",
@@ -721,9 +712,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Gardening system
- # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
- "sz": (
+ DeviceCategory.SZ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="power",
@@ -733,16 +722,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="pump",
),
),
- # Fingerbot
- "szjqr": (
+ DeviceCategory.SZJQR: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
),
),
- # IoT Switch?
- # Note: Undocumented
- "tdq": (
+ DeviceCategory.TDQ: (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_switch",
@@ -785,27 +771,21 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Solar Light
- # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
- "tyndj": (
+ DeviceCategory.TYNDJ: (
SwitchEntityDescription(
key=DPCode.SWITCH_SAVE_ENERGY,
translation_key="energy_saving",
entity_category=EntityCategory.CONFIG,
),
),
- # Gateway control
- # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok
- "wg2": (
+ DeviceCategory.WG2: (
SwitchEntityDescription(
key=DPCode.MUFFLING,
translation_key="mute",
entity_category=EntityCategory.CONFIG,
),
),
- # Thermostat
- # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
- "wk": (
+ DeviceCategory.WK: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
@@ -817,10 +797,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Two-way temperature and humidity switch
- # "MOES Temperature and Humidity Smart Switch Module MS-103"
- # Documentation not found
- "wkcz": (
+ DeviceCategory.WKCZ: (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_switch",
@@ -834,9 +811,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
device_class=SwitchDeviceClass.OUTLET,
),
),
- # Thermostatic Radiator Valve
- # Not documented
- "wkf": (
+ DeviceCategory.WKF: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
@@ -848,34 +823,34 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Air Conditioner Mate (Smart IR Socket)
- "wnykq": (
+ DeviceCategory.WNYKQ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
name=None,
),
),
- # SIREN: Siren (switch) with Temperature and humidity sensor
- # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek
- "wsdcg": (
+ DeviceCategory.WSDCG: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
device_class=SwitchDeviceClass.OUTLET,
),
),
- # Ceiling Light
- # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r
- "xdd": (
+ DeviceCategory.XDD: (
SwitchEntityDescription(
key=DPCode.DO_NOT_DISTURB,
translation_key="do_not_disturb",
entity_category=EntityCategory.CONFIG,
),
),
- # Diffuser
- # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl
- "xxj": (
+ DeviceCategory.XNYJCN: (
+ SwitchEntityDescription(
+ key=DPCode.FEEDIN_POWER_LIMIT_ENABLE,
+ translation_key="output_power_limit",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
+ DeviceCategory.XXJ: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="power",
@@ -890,32 +865,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
- # Smoke Detector
- # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
- "ywbj": (
+ DeviceCategory.YWBJ: (
SwitchEntityDescription(
key=DPCode.MUFFLING,
translation_key="mute",
entity_category=EntityCategory.CONFIG,
),
),
- # Smart Electricity Meter
- # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7
- "zndb": (
+ DeviceCategory.ZNDB: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
),
),
- # Hejhome whitelabel Fingerbot
- "znjxs": (
+ DeviceCategory.ZNJXS: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
),
),
- # Pool HeatPump
- "znrb": (
+ DeviceCategory.ZNRB: (
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
@@ -924,12 +893,10 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
}
# Socket (duplicate of `pc`)
-# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
-SWITCHES["cz"] = SWITCHES["pc"]
+SWITCHES[DeviceCategory.CZ] = SWITCHES[DeviceCategory.PC]
# Smart Camera - Low power consumption camera (duplicate of `sp`)
-# Undocumented, see https://github.com/home-assistant/core/issues/132844
-SWITCHES["dghsxj"] = SWITCHES["sp"]
+SWITCHES[DeviceCategory.DGHSXJ] = SWITCHES[DeviceCategory.SP]
async def async_setup_entry(
@@ -938,30 +905,86 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya sensors dynamically through tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
+ entity_registry = er.async_get(hass)
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya sensor."""
entities: list[TuyaSwitchEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := SWITCHES.get(device.category):
entities.extend(
- TuyaSwitchEntity(device, hass_data.manager, description)
+ TuyaSwitchEntity(device, manager, description)
for description in descriptions
if description.key in device.status
+ and _check_deprecation(
+ hass,
+ device,
+ description,
+ entity_registry,
+ )
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
)
+def _check_deprecation(
+ hass: HomeAssistant,
+ device: CustomerDevice,
+ description: SwitchEntityDescription,
+ entity_registry: er.EntityRegistry,
+) -> bool:
+ """Check entity deprecation.
+
+ Returns:
+ `True` if the entity should be created, `False` otherwise.
+ """
+ # Not deprecated, just create it
+ if not isinstance(description, TuyaDeprecatedSwitchEntityDescription):
+ return True
+
+ unique_id = f"tuya.{device.id}{description.key}"
+ entity_id = entity_registry.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id)
+
+ # Deprecated and not present in registry, skip creation
+ if not entity_id or not (entity_entry := entity_registry.async_get(entity_id)):
+ return False
+
+ # Deprecated and present in registry but disabled, remove it and skip creation
+ if entity_entry.disabled:
+ entity_registry.async_remove(entity_id)
+ async_delete_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_entity_{unique_id}",
+ )
+ return False
+
+ # Deprecated and present in registry and enabled, raise issue and create it
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_entity_{unique_id}",
+ breaks_in_ha_version=description.breaks_in_ha_version,
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key=description.deprecated,
+ translation_placeholders={
+ "name": f"{device.name} {entity_entry.name or entity_entry.original_name}",
+ "entity": entity_id,
+ },
+ )
+ return True
+
+
class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
"""Tuya Switch Device."""
diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py
index c32d773c792..8e0674ad23a 100644
--- a/homeassistant/components/tuya/vacuum.py
+++ b/homeassistant/components/tuya/vacuum.py
@@ -16,7 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData
from .util import get_dpcode
@@ -55,19 +55,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya vacuum dynamically through Tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya vacuum."""
entities: list[TuyaVacuumEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
- if device.category == "sd":
- entities.append(TuyaVacuumEntity(device, hass_data.manager))
+ device = manager.device_map[device_id]
+ if device.category == DeviceCategory.SD:
+ entities.append(TuyaVacuumEntity(device, manager))
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py
index 06218c7030f..f14d605c19a 100644
--- a/homeassistant/components/tuya/valve.py
+++ b/homeassistant/components/tuya/valve.py
@@ -15,15 +15,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
-from .const import TUYA_DISCOVERY_NEW, DPCode
+from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
-# All descriptions can be found here. Mostly the Boolean data types in the
-# default instruction set of each category end up being a Valve.
-# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
-VALVES: dict[str, tuple[ValveEntityDescription, ...]] = {
- # Smart Water Timer
- "sfkzq": (
+VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = {
+ DeviceCategory.SFKZQ: (
+ ValveEntityDescription(
+ key=DPCode.SWITCH,
+ translation_key="valve",
+ device_class=ValveDeviceClass.WATER,
+ ),
ValveEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_valve",
@@ -82,24 +83,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya valves dynamically through tuya discovery."""
- hass_data = entry.runtime_data
+ manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya valve."""
entities: list[TuyaValveEntity] = []
for device_id in device_ids:
- device = hass_data.manager.device_map[device_id]
+ device = manager.device_map[device_id]
if descriptions := VALVES.get(device.category):
entities.extend(
- TuyaValveEntity(device, hass_data.manager, description)
+ TuyaValveEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
- async_discover_device([*hass_data.manager.device_map])
+ async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py
index 010a9e90ccc..142c3509e0b 100644
--- a/homeassistant/components/twitch/coordinator.py
+++ b/homeassistant/components/twitch/coordinator.py
@@ -79,6 +79,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]):
if not (user := await first(self.twitch.get_users())):
raise UpdateFailed("Logged in user not found")
self.current_user = user
+ self.users.append(self.current_user) # Add current_user to users list.
async def _async_update_data(self) -> dict[str, TwitchUpdate]:
await self.session.async_ensure_token_valid()
@@ -95,6 +96,8 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]):
user_id=self.current_user.id, first=100
)
}
+ async for s in self.twitch.get_streams(user_id=[self.current_user.id]):
+ streams.update({s.user_id: s})
follows: dict[str, FollowedChannel] = {
f.broadcaster_id: f
async for f in await self.twitch.get_followed_channels(
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index c766af47951..0f2750ca5db 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
- "requirements": ["aiounifi==86"],
+ "requirements": ["aiounifi==87"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py
index 1ca409bec77..b9fbf48cf49 100644
--- a/homeassistant/components/unifi/switch.py
+++ b/homeassistant/components/unifi/switch.py
@@ -27,7 +27,10 @@ from aiounifi.interfaces.traffic_rules import TrafficRules
from aiounifi.interfaces.wlans import Wlans
from aiounifi.models.api import ApiItem
from aiounifi.models.client import Client, ClientBlockRequest
-from aiounifi.models.device import DeviceSetOutletRelayRequest
+from aiounifi.models.device import (
+ DeviceSetOutletRelayRequest,
+ DeviceSetPortEnabledRequest,
+)
from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest
from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup
from aiounifi.models.event import Event, EventKey
@@ -156,6 +159,14 @@ def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
return outlet.has_relay or outlet.caps in (1, 3)
+@callback
+def async_port_control_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
+ """Determine if a port supports switching."""
+ port = hub.api.ports[obj_id]
+ # Only allow switching for physical ports that exist
+ return port.port_idx is not None
+
+
async def async_outlet_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None:
"""Control outlet relay."""
mac, _, index = obj_id.partition("_")
@@ -174,6 +185,15 @@ async def async_poe_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) ->
hub.queue_poe_port_command(mac, int(index), state)
+async def async_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None:
+ """Control port enabled state."""
+ mac, _, index = obj_id.partition("_")
+ device = hub.api.devices[mac]
+ await hub.api.request(
+ DeviceSetPortEnabledRequest.create(device, int(index), target)
+ )
+
+
async def async_port_forward_control_fn(
hub: UnifiHub, obj_id: str, target: bool
) -> None:
@@ -338,6 +358,22 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = (
supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe),
unique_id_fn=lambda hub, obj_id: f"poe-{obj_id}",
),
+ UnifiSwitchEntityDescription[Ports, Port](
+ key="Port control",
+ translation_key="port_control",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ api_handler_fn=lambda api: api.ports,
+ available_fn=async_device_available_fn,
+ control_fn=async_port_control_fn,
+ device_info_fn=async_device_device_info_fn,
+ is_on_fn=lambda hub, port: bool(port.enabled),
+ name_fn=lambda port: port.name,
+ object_fn=lambda api, obj_id: api.ports[obj_id],
+ supported_fn=async_port_control_supported_fn,
+ unique_id_fn=lambda hub, obj_id: f"port-{obj_id}",
+ ),
UnifiSwitchEntityDescription[Wlans, Wlan](
key="WLAN control",
translation_key="wlan_control",
diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py
index 8f24d9046ae..a043a66e350 100644
--- a/homeassistant/components/unifiprotect/repairs.py
+++ b/homeassistant/components/unifiprotect/repairs.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import cast
from uiprotect import ProtectApiClient
-from uiprotect.data import Bootstrap, Camera, ModelType
+from uiprotect.data import Bootstrap, Camera
import voluptuous as vol
from homeassistant import data_entry_flow
@@ -114,16 +114,7 @@ class RTSPRepair(ProtectRepair):
async def _enable_rtsp(self) -> None:
camera = await self._get_camera()
- bootstrap = await self._get_boostrap()
- user = bootstrap.users.get(bootstrap.auth_user_id)
- if not user or not camera.can_write(user):
- return
-
- channel = camera.channels[0]
- channel.is_rtsp_enabled = True
- await self._api.update_device(
- ModelType.CAMERA, camera.id, {"channels": camera.unifi_dict()["channels"]}
- )
+ await camera.create_rtsps_streams(qualities="high")
async def async_step_init(
self, user_input: dict[str, str] | None = None
diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py
index 25188eb3a5d..47079a1eef5 100644
--- a/homeassistant/components/universal/media_player.py
+++ b/homeassistant/components/universal/media_player.py
@@ -365,7 +365,7 @@ class UniversalMediaPlayer(MediaPlayerEntity):
@property
def media_image_url(self):
"""Image url of current playing media."""
- return self._child_attr(ATTR_ENTITY_PICTURE)
+ return self._override_or_child_attr(ATTR_ENTITY_PICTURE)
@property
def entity_picture(self):
diff --git a/homeassistant/components/upcloud/manifest.json b/homeassistant/components/upcloud/manifest.json
index 38581d31709..ab79d3f5c1a 100644
--- a/homeassistant/components/upcloud/manifest.json
+++ b/homeassistant/components/upcloud/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/upcloud",
"iot_class": "cloud_polling",
- "requirements": ["upcloud-api==2.6.0"]
+ "requirements": ["upcloud-api==2.9.0"]
}
diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json
index 5194965cf69..a90f5c8a998 100644
--- a/homeassistant/components/update/strings.json
+++ b/homeassistant/components/update/strings.json
@@ -5,6 +5,9 @@
"changed_states": "{entity_name} update availability changed",
"turned_on": "{entity_name} got an update available",
"turned_off": "{entity_name} became up-to-date"
+ },
+ "extra_fields": {
+ "for": "[%key:common::device_automation::extra_fields::for%]"
}
},
"entity_component": {
diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py
index e8803b6ad89..0fed98ed4a6 100644
--- a/homeassistant/components/uptimerobot/binary_sensor.py
+++ b/homeassistant/components/uptimerobot/binary_sensor.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from pyuptimerobot import UptimeRobotMonitor
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -12,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
+from .utils import new_device_listener
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -24,17 +27,24 @@ async def async_setup_entry(
) -> None:
"""Set up the UptimeRobot binary_sensors."""
coordinator = entry.runtime_data
- async_add_entities(
- UptimeRobotBinarySensor(
- coordinator,
- BinarySensorEntityDescription(
- key=str(monitor.id),
- device_class=BinarySensorDeviceClass.CONNECTIVITY,
- ),
- monitor=monitor,
- )
- for monitor in coordinator.data
- )
+
+ def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
+ """Add entities for new monitors."""
+ entities = [
+ UptimeRobotBinarySensor(
+ coordinator,
+ BinarySensorEntityDescription(
+ key=str(monitor.id),
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ ),
+ monitor=monitor,
+ )
+ for monitor in new_monitors
+ ]
+ if entities:
+ async_add_entities(entities)
+
+ entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity):
diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py
index 5fc165c0f27..ccbf6c39655 100644
--- a/homeassistant/components/uptimerobot/config_flow.py
+++ b/homeassistant/components/uptimerobot/config_flow.py
@@ -116,3 +116,30 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the device."""
+ reconfigure_entry = self._get_reconfigure_entry()
+ if not user_input:
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ )
+
+ self._async_abort_entries_match(
+ {CONF_API_KEY: reconfigure_entry.data[CONF_API_KEY]}
+ )
+
+ errors, account = await self._validate_input(user_input)
+ if account:
+ await self.async_set_unique_id(str(account.user_id))
+ self._abort_if_unique_id_configured()
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, data_updates=user_input
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py
index 7ecb1ee3313..78866800eff 100644
--- a/homeassistant/components/uptimerobot/coordinator.py
+++ b/homeassistant/components/uptimerobot/coordinator.py
@@ -65,7 +65,10 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon
if device := device_registry.async_get_device(
identifiers={(DOMAIN, monitor_id)}
):
- device_registry.async_remove_device(device.id)
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
# If there are new monitors, we should reload the config entry so we can
# create new devices and entities.
diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml
index 1244d6a4c19..de85152315a 100644
--- a/homeassistant/components/uptimerobot/quality_scale.yaml
+++ b/homeassistant/components/uptimerobot/quality_scale.yaml
@@ -57,9 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
- dynamic-devices:
- status: todo
- comment: create entities on runtime instead of triggering a reload
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:
@@ -68,15 +66,11 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
- reconfiguration-flow:
- status: todo
- comment: handle API key change/update
+ reconfiguration-flow: done
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
- stale-devices:
- status: todo
- comment: We should remove the config entry from the device rather than remove the device
+ stale-devices: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py
index 3ed97d17508..633ac8243ff 100644
--- a/homeassistant/components/uptimerobot/sensor.py
+++ b/homeassistant/components/uptimerobot/sensor.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from pyuptimerobot import UptimeRobotMonitor
+
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -13,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
+from .utils import new_device_listener
SENSORS_INFO = {
0: "pause",
@@ -33,20 +36,33 @@ async def async_setup_entry(
) -> None:
"""Set up the UptimeRobot sensors."""
coordinator = entry.runtime_data
- async_add_entities(
- UptimeRobotSensor(
- coordinator,
- SensorEntityDescription(
- key=str(monitor.id),
- entity_category=EntityCategory.DIAGNOSTIC,
- device_class=SensorDeviceClass.ENUM,
- options=["down", "not_checked_yet", "pause", "seems_down", "up"],
- translation_key="monitor_status",
- ),
- monitor=monitor,
- )
- for monitor in coordinator.data
- )
+
+ def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
+ """Add entities for new monitors."""
+ entities = [
+ UptimeRobotSensor(
+ coordinator,
+ SensorEntityDescription(
+ key=str(monitor.id),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "down",
+ "not_checked_yet",
+ "pause",
+ "seems_down",
+ "up",
+ ],
+ translation_key="monitor_status",
+ ),
+ monitor=monitor,
+ )
+ for monitor in new_monitors
+ ]
+ if entities:
+ async_add_entities(entities)
+
+ entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotSensor(UptimeRobotEntity, SensorEntity):
diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json
index ffee6769c69..f912b6dd993 100644
--- a/homeassistant/components/uptimerobot/strings.json
+++ b/homeassistant/components/uptimerobot/strings.json
@@ -17,6 +17,14 @@
"data_description": {
"api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]"
}
+ },
+ "reconfigure": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]"
+ }
}
},
"error": {
@@ -30,6 +38,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py
index 5d80903ed02..b75f099db73 100644
--- a/homeassistant/components/uptimerobot/switch.py
+++ b/homeassistant/components/uptimerobot/switch.py
@@ -4,7 +4,11 @@ from __future__ import annotations
from typing import Any
-from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException
+from pyuptimerobot import (
+ UptimeRobotAuthenticationException,
+ UptimeRobotException,
+ UptimeRobotMonitor,
+)
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -18,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_ATTR_OK, DOMAIN
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
+from .utils import new_device_listener
# Limit the number of parallel updates to 1
PARALLEL_UPDATES = 1
@@ -30,17 +35,24 @@ async def async_setup_entry(
) -> None:
"""Set up the UptimeRobot switches."""
coordinator = entry.runtime_data
- async_add_entities(
- UptimeRobotSwitch(
- coordinator,
- SwitchEntityDescription(
- key=str(monitor.id),
- device_class=SwitchDeviceClass.SWITCH,
- ),
- monitor=monitor,
- )
- for monitor in coordinator.data
- )
+
+ def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None:
+ """Add entities for new monitors."""
+ entities = [
+ UptimeRobotSwitch(
+ coordinator,
+ SwitchEntityDescription(
+ key=str(monitor.id),
+ device_class=SwitchDeviceClass.SWITCH,
+ ),
+ monitor=monitor,
+ )
+ for monitor in new_monitors
+ ]
+ if entities:
+ async_add_entities(entities)
+
+ entry.async_on_unload(new_device_listener(coordinator, _add_new_entities))
class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity):
diff --git a/homeassistant/components/uptimerobot/utils.py b/homeassistant/components/uptimerobot/utils.py
new file mode 100644
index 00000000000..522324cf6f3
--- /dev/null
+++ b/homeassistant/components/uptimerobot/utils.py
@@ -0,0 +1,34 @@
+"""Utility functions for the UptimeRobot integration."""
+
+from collections.abc import Callable
+
+from pyuptimerobot import UptimeRobotMonitor
+
+from .coordinator import UptimeRobotDataUpdateCoordinator
+
+
+def new_device_listener(
+ coordinator: UptimeRobotDataUpdateCoordinator,
+ new_devices_callback: Callable[[list[UptimeRobotMonitor]], None],
+) -> Callable[[], None]:
+ """Subscribe to coordinator updates to check for new devices."""
+ known_devices: set[int] = set()
+
+ def _check_devices() -> None:
+ """Check for new devices and call callback with any new monitors."""
+ if not coordinator.data:
+ return
+
+ new_monitors: list[UptimeRobotMonitor] = []
+ for monitor in coordinator.data:
+ if monitor.id not in known_devices:
+ known_devices.add(monitor.id)
+ new_monitors.append(monitor)
+
+ if new_monitors:
+ new_devices_callback(new_monitors)
+
+ # Check for devices immediately
+ _check_devices()
+
+ return coordinator.async_add_listener(_check_devices)
diff --git a/homeassistant/components/usage_prediction/__init__.py b/homeassistant/components/usage_prediction/__init__.py
new file mode 100644
index 00000000000..0388591c323
--- /dev/null
+++ b/homeassistant/components/usage_prediction/__init__.py
@@ -0,0 +1,89 @@
+"""The usage prediction integration."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import timedelta
+from typing import Any
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
+
+from . import common_control
+from .const import DATA_CACHE, DOMAIN
+from .models import EntityUsageDataCache, EntityUsagePredictions
+
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+CACHE_DURATION = timedelta(hours=24)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the usage prediction integration."""
+ websocket_api.async_register_command(hass, ws_common_control)
+ hass.data[DATA_CACHE] = {}
+ return True
+
+
+@websocket_api.websocket_command({"type": f"{DOMAIN}/common_control"})
+@websocket_api.async_response
+async def ws_common_control(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Handle usage prediction common control WebSocket API."""
+ result = await get_cached_common_control(hass, connection.user.id)
+ time_category = common_control.time_category(dt_util.now().hour)
+ connection.send_result(
+ msg["id"],
+ {
+ "entities": getattr(result, time_category),
+ },
+ )
+
+
+async def get_cached_common_control(
+ hass: HomeAssistant, user_id: str
+) -> EntityUsagePredictions:
+ """Get cached common control predictions or fetch new ones.
+
+ Returns cached data if it's less than 24 hours old,
+ otherwise fetches new data and caches it.
+ """
+ # Create a unique storage key for this user
+ storage_key = user_id
+
+ cached_data = hass.data[DATA_CACHE].get(storage_key)
+
+ if isinstance(cached_data, asyncio.Task):
+ # If there's an ongoing task to fetch data, await its result
+ return await cached_data
+
+ # Check if cache is valid (less than 24 hours old)
+ if cached_data is not None:
+ if (dt_util.utcnow() - cached_data.timestamp) < CACHE_DURATION:
+ # Cache is still valid, return the cached predictions
+ return cached_data.predictions
+
+ # Create task fetching data
+ task = hass.async_create_task(
+ common_control.async_predict_common_control(hass, user_id)
+ )
+ hass.data[DATA_CACHE][storage_key] = task
+
+ try:
+ predictions = await task
+ except Exception:
+ # If the task fails, remove it from cache to allow retries
+ hass.data[DATA_CACHE].pop(storage_key)
+ raise
+
+ hass.data[DATA_CACHE][storage_key] = EntityUsageDataCache(
+ predictions=predictions,
+ )
+
+ return predictions
diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py
new file mode 100644
index 00000000000..69f2164fc76
--- /dev/null
+++ b/homeassistant/components/usage_prediction/common_control.py
@@ -0,0 +1,236 @@
+"""Code to generate common control usage patterns."""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Callable, Sequence
+from datetime import datetime, timedelta
+from functools import cache
+import logging
+from typing import Any, Literal, cast
+
+from sqlalchemy import select
+from sqlalchemy.engine.row import Row
+from sqlalchemy.orm import Session
+
+from homeassistant.components.recorder import get_instance
+from homeassistant.components.recorder.db_schema import EventData, Events, EventTypes
+from homeassistant.components.recorder.models import uuid_hex_to_bytes_or_none
+from homeassistant.components.recorder.util import session_scope
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.util import dt as dt_util
+from homeassistant.util.json import json_loads_object
+
+from .models import EntityUsagePredictions
+
+_LOGGER = logging.getLogger(__name__)
+
+# Time categories for usage patterns
+TIME_CATEGORIES = ["morning", "afternoon", "evening", "night"]
+
+RESULTS_TO_INCLUDE = 8
+
+# List of domains for which we want to track usage
+ALLOWED_DOMAINS = {
+ # Entity platforms
+ Platform.AIR_QUALITY,
+ Platform.ALARM_CONTROL_PANEL,
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.CAMERA,
+ Platform.CLIMATE,
+ Platform.COVER,
+ Platform.FAN,
+ Platform.HUMIDIFIER,
+ Platform.LAWN_MOWER,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.MEDIA_PLAYER,
+ Platform.NUMBER,
+ Platform.SCENE,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SIREN,
+ Platform.SWITCH,
+ Platform.VACUUM,
+ Platform.VALVE,
+ Platform.WATER_HEATER,
+ # Helpers with own domain
+ "counter",
+ "group",
+ "input_boolean",
+ "input_button",
+ "input_datetime",
+ "input_number",
+ "input_select",
+ "input_text",
+ "schedule",
+ "timer",
+}
+
+
+@cache
+def time_category(hour: int) -> Literal["morning", "afternoon", "evening", "night"]:
+ """Determine the time category for a given hour."""
+ if 6 <= hour < 12:
+ return "morning"
+ if 12 <= hour < 18:
+ return "afternoon"
+ if 18 <= hour < 22:
+ return "evening"
+ return "night"
+
+
+async def async_predict_common_control(
+ hass: HomeAssistant, user_id: str
+) -> EntityUsagePredictions:
+ """Generate a list of commonly used entities for a user.
+
+ Args:
+ hass: Home Assistant instance
+ user_id: User ID to filter events by.
+ """
+ # Get the recorder instance to ensure it's ready
+ recorder = get_instance(hass)
+ ent_reg = er.async_get(hass)
+
+ # Execute the database operation in the recorder's executor
+ data = await recorder.async_add_executor_job(
+ _fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id
+ )
+ # Prepare a dictionary to track results
+ results: dict[str, Counter[str]] = {
+ time_cat: Counter() for time_cat in TIME_CATEGORIES
+ }
+
+ allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS))
+ hidden_entities: set[str] = set()
+
+ # Keep track of contexts that we processed so that we will only process
+ # the first service call in a context, and not subsequent calls.
+ context_processed: set[bytes] = set()
+ # Execute the query
+ context_id: bytes
+ time_fired_ts: float
+ shared_data: str | None
+ local_time_zone = dt_util.get_default_time_zone()
+ for context_id, time_fired_ts, shared_data in data:
+ # Skip if we have already processed an event that was part of this context
+ if context_id in context_processed:
+ continue
+
+ # Mark this context as processed
+ context_processed.add(context_id)
+
+ # Parse the event data
+ if not time_fired_ts or not shared_data:
+ continue
+
+ try:
+ event_data = json_loads_object(shared_data)
+ except (ValueError, TypeError) as err:
+ _LOGGER.debug("Failed to parse event data: %s", err)
+ continue
+
+ # Empty event data, skipping
+ if not event_data:
+ continue
+
+ service_data = cast(dict[str, Any] | None, event_data.get("service_data"))
+
+ # No service data found, skipping
+ if not service_data:
+ continue
+
+ entity_ids: str | list[str] | None
+ if (target := service_data.get("target")) and (
+ target_entity_ids := target.get("entity_id")
+ ):
+ entity_ids = target_entity_ids
+ else:
+ entity_ids = service_data.get("entity_id")
+
+ # No entity IDs found, skip this event
+ if entity_ids is None:
+ continue
+
+ if not isinstance(entity_ids, list):
+ entity_ids = [entity_ids]
+
+ # Convert to local time for time category determination
+ period = time_category(
+ datetime.fromtimestamp(time_fired_ts, local_time_zone).hour
+ )
+ period_results = results[period]
+
+ # Count entity usage
+ for entity_id in entity_ids:
+ if entity_id not in allowed_entities or entity_id in hidden_entities:
+ continue
+
+ if (
+ entity_id not in period_results
+ and (entry := ent_reg.async_get(entity_id))
+ and entry.hidden
+ ):
+ hidden_entities.add(entity_id)
+ continue
+
+ period_results[entity_id] += 1
+
+ return EntityUsagePredictions(
+ morning=[
+ ent_id for (ent_id, _) in results["morning"].most_common(RESULTS_TO_INCLUDE)
+ ],
+ afternoon=[
+ ent_id
+ for (ent_id, _) in results["afternoon"].most_common(RESULTS_TO_INCLUDE)
+ ],
+ evening=[
+ ent_id for (ent_id, _) in results["evening"].most_common(RESULTS_TO_INCLUDE)
+ ],
+ night=[
+ ent_id for (ent_id, _) in results["night"].most_common(RESULTS_TO_INCLUDE)
+ ],
+ )
+
+
+def _fetch_and_process_data(
+ session: Session, ent_reg: er.EntityRegistry, user_id: str
+) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]:
+ """Fetch and process service call events from the database."""
+ thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp()
+ user_id_bytes = uuid_hex_to_bytes_or_none(user_id)
+ if not user_id_bytes:
+ raise ValueError("Invalid user_id format")
+
+ # Build the main query for events with their data
+ query = (
+ select(
+ Events.context_id_bin,
+ Events.time_fired_ts,
+ EventData.shared_data,
+ )
+ .select_from(Events)
+ .outerjoin(EventData, Events.data_id == EventData.data_id)
+ .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id)
+ .where(Events.time_fired_ts >= thirty_days_ago_ts)
+ .where(Events.context_user_id_bin == user_id_bytes)
+ .where(EventTypes.event_type == "call_service")
+ .order_by(Events.time_fired_ts)
+ )
+ return session.connection().execute(query).all()
+
+
+def _fetch_with_session(
+ hass: HomeAssistant,
+ fetch_func: Callable[
+ [Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]]
+ ],
+ *args: object,
+) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]:
+ """Execute a fetch function with a database session."""
+ with session_scope(hass=hass, read_only=True) as session:
+ return fetch_func(session, *args)
diff --git a/homeassistant/components/usage_prediction/const.py b/homeassistant/components/usage_prediction/const.py
new file mode 100644
index 00000000000..65aeb1773fe
--- /dev/null
+++ b/homeassistant/components/usage_prediction/const.py
@@ -0,0 +1,13 @@
+"""Constants for the usage prediction integration."""
+
+import asyncio
+
+from homeassistant.util.hass_dict import HassKey
+
+from .models import EntityUsageDataCache, EntityUsagePredictions
+
+DOMAIN = "usage_prediction"
+
+DATA_CACHE: HassKey[
+ dict[str, asyncio.Task[EntityUsagePredictions] | EntityUsageDataCache]
+] = HassKey("usage_prediction")
diff --git a/homeassistant/components/usage_prediction/manifest.json b/homeassistant/components/usage_prediction/manifest.json
new file mode 100644
index 00000000000..a1f4d4e7cf2
--- /dev/null
+++ b/homeassistant/components/usage_prediction/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "usage_prediction",
+ "name": "Usage Prediction",
+ "codeowners": ["@home-assistant/core"],
+ "dependencies": ["http", "recorder"],
+ "documentation": "https://www.home-assistant.io/integrations/usage_prediction",
+ "integration_type": "system",
+ "iot_class": "calculated",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/usage_prediction/models.py b/homeassistant/components/usage_prediction/models.py
new file mode 100644
index 00000000000..53f976f89e4
--- /dev/null
+++ b/homeassistant/components/usage_prediction/models.py
@@ -0,0 +1,24 @@
+"""Models for the usage prediction integration."""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from homeassistant.util import dt as dt_util
+
+
+@dataclass
+class EntityUsagePredictions:
+ """Prediction which entities are likely to be used in each time category."""
+
+ morning: list[str] = field(default_factory=list)
+ afternoon: list[str] = field(default_factory=list)
+ evening: list[str] = field(default_factory=list)
+ night: list[str] = field(default_factory=list)
+
+
+@dataclass
+class EntityUsageDataCache:
+ """Data model for entity usage prediction."""
+
+ predictions: EntityUsagePredictions
+ timestamp: datetime = field(default_factory=dt_util.utcnow)
diff --git a/homeassistant/components/usage_prediction/strings.json b/homeassistant/components/usage_prediction/strings.json
new file mode 100644
index 00000000000..56ab70d2360
--- /dev/null
+++ b/homeassistant/components/usage_prediction/strings.json
@@ -0,0 +1,3 @@
+{
+ "title": "Usage Prediction"
+}
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
index 8a388058b19..a79881d3983 100644
--- a/homeassistant/components/utility_meter/__init__.py
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -228,6 +228,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
)
+ hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
async_handle_source_entity_changes(
@@ -258,17 +259,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, (Platform.SELECT, Platform.SENSOR)
)
- entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
-
return True
-async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Update listener, called when the config entry options are changed."""
-
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
platforms_to_unload = [Platform.SENSOR]
diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py
index 933a04accba..06706c79216 100644
--- a/homeassistant/components/utility_meter/config_flow.py
+++ b/homeassistant/components/utility_meter/config_flow.py
@@ -134,6 +134,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py
index 081b7a15995..13389c6e797 100644
--- a/homeassistant/components/vacuum/__init__.py
+++ b/homeassistant/components/vacuum/__init__.py
@@ -104,41 +104,6 @@ class VacuumEntityFeature(IntFlag):
START = 8192
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the VacuumEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum(
- VacuumEntityFeature.TURN_ON, "2025.10"
-)
-_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum(
- VacuumEntityFeature.TURN_OFF, "2025.10"
-)
-_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum(VacuumEntityFeature.PAUSE, "2025.10")
-_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(VacuumEntityFeature.STOP, "2025.10")
-_DEPRECATED_SUPPORT_RETURN_HOME = DeprecatedConstantEnum(
- VacuumEntityFeature.RETURN_HOME, "2025.10"
-)
-_DEPRECATED_SUPPORT_FAN_SPEED = DeprecatedConstantEnum(
- VacuumEntityFeature.FAN_SPEED, "2025.10"
-)
-_DEPRECATED_SUPPORT_BATTERY = DeprecatedConstantEnum(
- VacuumEntityFeature.BATTERY, "2025.10"
-)
-_DEPRECATED_SUPPORT_STATUS = DeprecatedConstantEnum(
- VacuumEntityFeature.STATUS, "2025.10"
-)
-_DEPRECATED_SUPPORT_SEND_COMMAND = DeprecatedConstantEnum(
- VacuumEntityFeature.SEND_COMMAND, "2025.10"
-)
-_DEPRECATED_SUPPORT_LOCATE = DeprecatedConstantEnum(
- VacuumEntityFeature.LOCATE, "2025.10"
-)
-_DEPRECATED_SUPPORT_CLEAN_SPOT = DeprecatedConstantEnum(
- VacuumEntityFeature.CLEAN_SPOT, "2025.10"
-)
-_DEPRECATED_SUPPORT_MAP = DeprecatedConstantEnum(VacuumEntityFeature.MAP, "2025.10")
-_DEPRECATED_SUPPORT_STATE = DeprecatedConstantEnum(VacuumEntityFeature.STATE, "2025.10")
-_DEPRECATED_SUPPORT_START = DeprecatedConstantEnum(VacuumEntityFeature.START, "2025.10")
-
# mypy: disallow-any-generics
diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py
index 48340252b6e..c5edbbd0338 100644
--- a/homeassistant/components/vacuum/intent.py
+++ b/homeassistant/components/vacuum/intent.py
@@ -3,7 +3,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
-from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START
+from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature
INTENT_VACUUM_START = "HassVacuumStart"
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
@@ -20,6 +20,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Starts a vacuum",
required_domains={DOMAIN},
platforms={DOMAIN},
+ required_features=VacuumEntityFeature.START,
),
)
intent.async_register(
@@ -31,5 +32,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Returns a vacuum to base",
required_domains={DOMAIN},
platforms={DOMAIN},
+ required_features=VacuumEntityFeature.RETURN_HOME,
),
)
diff --git a/homeassistant/components/vegehub/const.py b/homeassistant/components/vegehub/const.py
index 960ea4d3a91..ed9a115404a 100644
--- a/homeassistant/components/vegehub/const.py
+++ b/homeassistant/components/vegehub/const.py
@@ -4,6 +4,6 @@ from homeassistant.const import Platform
DOMAIN = "vegehub"
NAME = "VegeHub"
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
MANUFACTURER = "vegetronix"
MODEL = "VegeHub"
diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json
index c35fe0d83c9..3566a9d6a8c 100644
--- a/homeassistant/components/vegehub/strings.json
+++ b/homeassistant/components/vegehub/strings.json
@@ -39,6 +39,11 @@
"battery_volts": {
"name": "Battery voltage"
}
+ },
+ "switch": {
+ "switch": {
+ "name": "Actuator {index}"
+ }
}
}
}
diff --git a/homeassistant/components/vegehub/switch.py b/homeassistant/components/vegehub/switch.py
new file mode 100644
index 00000000000..aacb7330a55
--- /dev/null
+++ b/homeassistant/components/vegehub/switch.py
@@ -0,0 +1,80 @@
+"""Switch configuration for VegeHub integration."""
+
+from typing import Any
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import VegeHubConfigEntry, VegeHubCoordinator
+from .entity import VegeHubEntity
+
+SWITCH_TYPES: dict[str, SwitchEntityDescription] = {
+ "switch": SwitchEntityDescription(
+ key="switch",
+ translation_key="switch",
+ device_class=SwitchDeviceClass.SWITCH,
+ )
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: VegeHubConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up VegeHub switches from a config entry."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ VegeHubSwitch(
+ index=i,
+ duration=600, # Default duration of 10 minutes
+ coordinator=coordinator,
+ description=SWITCH_TYPES["switch"],
+ )
+ for i in range(coordinator.vegehub.num_actuators)
+ )
+
+
+class VegeHubSwitch(VegeHubEntity, SwitchEntity):
+ """Class for VegeHub Switches."""
+
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
+ def __init__(
+ self,
+ index: int,
+ duration: int,
+ coordinator: VegeHubCoordinator,
+ description: SwitchEntityDescription,
+ ) -> None:
+ """Initialize the switch."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ # Set unique ID for pulling data from the coordinator
+ self.data_key = f"actuator_{index}"
+ self._attr_unique_id = f"{self._mac_address}_{self.data_key}"
+ self._attr_translation_placeholders = {"index": str(index + 1)}
+ self._attr_available = False
+ self.index = index
+ self.duration = duration
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if the switch is on."""
+ if self.coordinator.data is None or self._attr_unique_id is None:
+ return False
+ return self.coordinator.data.get(self.data_key, 0) > 0
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self.coordinator.vegehub.set_actuator(1, self.index, self.duration)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self.coordinator.vegehub.set_actuator(0, self.index, self.duration)
diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py
index e08d4bcf545..de89005fa67 100644
--- a/homeassistant/components/velux/binary_sensor.py
+++ b/homeassistant/components/velux/binary_sensor.py
@@ -1,4 +1,4 @@
-"""Support for rain sensors build into some velux windows."""
+"""Support for rain sensors built into some Velux windows."""
from __future__ import annotations
@@ -44,12 +44,12 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
_attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices
_attr_entity_registry_enabled_default = False
_attr_device_class = BinarySensorDeviceClass.MOISTURE
+ _attr_translation_key = "rain_sensor"
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxRainSensor."""
super().__init__(node, config_entry_id)
self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor"
- self._attr_name = f"{node.name} Rain sensor"
async def async_update(self) -> None:
"""Fetch the latest state from the device."""
@@ -59,5 +59,6 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
LOGGER.error("Error fetching limitation data for cover %s", self.name)
return
- # Velux windows with rain sensors report an opening limitation of 93 when rain is detected.
- self._attr_is_on = limitation.min_value == 93
+ # Velux windows with rain sensors report an opening limitation of 93 or 100 (Velux GPU) when rain is detected.
+ # So far, only 93 and 100 have been observed in practice, documentation on this is non-existent AFAIK.
+ self._attr_is_on = limitation.min_value in {93, 100}
diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py
index d6bf8905d91..f31c4877ffd 100644
--- a/homeassistant/components/velux/cover.py
+++ b/homeassistant/components/velux/cover.py
@@ -4,8 +4,15 @@ from __future__ import annotations
from typing import Any, cast
-from pyvlx import OpeningDevice, Position
-from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window
+from pyvlx import (
+ Awning,
+ Blind,
+ GarageDoor,
+ Gate,
+ OpeningDevice,
+ Position,
+ RollerShutter,
+)
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -44,9 +51,13 @@ class VeluxCover(VeluxEntity, CoverEntity):
_is_blind = False
node: OpeningDevice
+ # Do not name the "main" feature of the device (position control)
+ _attr_name = None
+
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxCover."""
super().__init__(node, config_entry_id)
+ # Window is the default device class for covers
self._attr_device_class = CoverDeviceClass.WINDOW
if isinstance(node, Awning):
self._attr_device_class = CoverDeviceClass.AWNING
@@ -59,8 +70,6 @@ class VeluxCover(VeluxEntity, CoverEntity):
self._attr_device_class = CoverDeviceClass.GATE
if isinstance(node, RollerShutter):
self._attr_device_class = CoverDeviceClass.SHUTTER
- if isinstance(node, Window):
- self._attr_device_class = CoverDeviceClass.WINDOW
@property
def supported_features(self) -> CoverEntityFeature:
@@ -95,7 +104,10 @@ class VeluxCover(VeluxEntity, CoverEntity):
@property
def is_closed(self) -> bool:
"""Return if the cover is closed."""
- return self.node.position.closed
+ # do not use the node's closed state but rely on cover position
+ # until https://github.com/Julius2342/pyvlx/pull/543 is merged.
+ # once merged this can again return self.node.position.closed
+ return self.current_cover_position == 0
@property
def is_opening(self) -> bool:
diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py
index 1231a98e0a8..fa06598f979 100644
--- a/homeassistant/components/velux/entity.py
+++ b/homeassistant/components/velux/entity.py
@@ -3,13 +3,17 @@
from pyvlx import Node
from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
+from .const import DOMAIN
+
class VeluxEntity(Entity):
- """Abstraction for al Velux entities."""
+ """Abstraction for all Velux entities."""
_attr_should_poll = False
+ _attr_has_entity_name = True
def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device."""
@@ -19,7 +23,18 @@ class VeluxEntity(Entity):
if node.serial_number
else f"{config_entry_id}_{node.node_id}"
)
- self._attr_name = node.name if node.name else f"#{node.node_id}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (
+ DOMAIN,
+ node.serial_number
+ if node.serial_number
+ else f"{config_entry_id}_{node.node_id}",
+ )
+ },
+ name=node.name if node.name else f"#{node.node_id}",
+ serial_number=node.serial_number,
+ )
@callback
def async_register_callbacks(self):
diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json
index cb21fef299d..11e939fdfe7 100644
--- a/homeassistant/components/velux/manifest.json
+++ b/homeassistant/components/velux/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "velux",
"name": "Velux",
- "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"],
+ "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"],
"config_flow": true,
"dhcp": [
{
diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json
index 0cf578732fb..5123c59fe43 100644
--- a/homeassistant/components/velux/strings.json
+++ b/homeassistant/components/velux/strings.json
@@ -27,5 +27,12 @@
"name": "Reboot gateway",
"description": "Reboots the KLF200 Gateway."
}
+ },
+ "entity": {
+ "binary_sensor": {
+ "rain_sensor": {
+ "name": "Rain sensor"
+ }
+ }
}
}
diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py
index 7ead1f014c8..db199b180f4 100644
--- a/homeassistant/components/verisure/alarm_control_panel.py
+++ b/homeassistant/components/verisure/alarm_control_panel.py
@@ -67,8 +67,13 @@ class VerisureAlarm(
)
LOGGER.debug("Verisure set arm state %s", state)
result = None
+ attempts = 0
while result is None:
- await asyncio.sleep(0.5)
+ if attempts == 30:
+ break
+ if attempts > 1:
+ await asyncio.sleep(0.5)
+ attempts += 1
transaction = await self.hass.async_add_executor_job(
self.coordinator.verisure.request,
self.coordinator.verisure.poll_arm_state(
@@ -81,8 +86,10 @@ class VerisureAlarm(
.get("armStateChangePollResult", {})
.get("result")
)
-
- await self.coordinator.async_refresh()
+ LOGGER.debug("Result is %s", result)
+ if result == "OK":
+ self._attr_alarm_state = ALARM_STATE_TO_HA.get(state)
+ self.async_write_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -108,16 +115,20 @@ class VerisureAlarm(
"ARMED_AWAY", self.coordinator.verisure.arm_away(code)
)
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
+ def _update_alarm_attributes(self) -> None:
+ """Update alarm state and changed by from coordinator data."""
self._attr_alarm_state = ALARM_STATE_TO_HA.get(
self.coordinator.data["alarm"]["statusType"]
)
self._attr_changed_by = self.coordinator.data["alarm"].get("name")
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_alarm_attributes()
super()._handle_coordinator_update()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
- self._handle_coordinator_update()
+ self._update_alarm_attributes()
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
index 76aeedd05fa..4d2229967a0 100644
--- a/homeassistant/components/verisure/lock.py
+++ b/homeassistant/components/verisure/lock.py
@@ -10,7 +10,7 @@ from verisure import Error as VerisureError
from homeassistant.components.lock import LockEntity, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CODE
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
@@ -70,7 +70,9 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
self._attr_unique_id = serial_number
self.serial_number = serial_number
- self._state: str | None = None
+ self._attr_is_locked = None
+ self._attr_changed_by = None
+ self._changed_method: str | None = None
@property
def device_info(self) -> DeviceInfo:
@@ -92,20 +94,6 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
super().available and self.serial_number in self.coordinator.data["locks"]
)
- @property
- def changed_by(self) -> str | None:
- """Last change triggered by."""
- return (
- self.coordinator.data["locks"][self.serial_number]
- .get("user", {})
- .get("name")
- )
-
- @property
- def changed_method(self) -> str:
- """Last change method."""
- return self.coordinator.data["locks"][self.serial_number]["lockMethod"]
-
@property
def code_format(self) -> str:
"""Return the configured code format."""
@@ -115,16 +103,9 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
return f"^\\d{{{digits}}}$"
@property
- def is_locked(self) -> bool:
- """Return true if lock is locked."""
- return (
- self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED"
- )
-
- @property
- def extra_state_attributes(self) -> dict[str, str]:
+ def extra_state_attributes(self) -> dict[str, str | None]:
"""Return the state attributes."""
- return {"method": self.changed_method}
+ return {"method": self._changed_method}
async def async_unlock(self, **kwargs: Any) -> None:
"""Send unlock command."""
@@ -154,7 +135,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
target_state = "LOCKED" if state == LockState.LOCKED else "UNLOCKED"
lock_status = None
attempts = 0
- while lock_status != "OK":
+ while lock_status is None:
if attempts == 30:
break
if attempts > 1:
@@ -172,8 +153,10 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
.get("doorLockStateChangePollResult", {})
.get("result")
)
+ LOGGER.debug("Lock status is %s", lock_status)
if lock_status == "OK":
- self._state = state
+ self._attr_is_locked = state == LockState.LOCKED
+ self.async_write_ha_state()
def disable_autolock(self) -> None:
"""Disable autolock on a doorlock."""
@@ -196,3 +179,21 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt
LOGGER.debug("Enabling autolock on %s", self.serial_number)
except VerisureError as ex:
LOGGER.error("Could not enable autolock, %s", ex)
+
+ def _update_lock_attributes(self) -> None:
+ """Update lock state, changed by, and method from coordinator data."""
+ lock_data = self.coordinator.data["locks"][self.serial_number]
+ self._attr_is_locked = lock_data["lockStatus"] == "LOCKED"
+ self._attr_changed_by = lock_data.get("user", {}).get("name")
+ self._changed_method = lock_data["lockMethod"]
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_lock_attributes()
+ super()._handle_coordinator_update()
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self._update_lock_attributes()
diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py
index 0deb1da5e95..bdd933c753b 100644
--- a/homeassistant/components/verisure/switch.py
+++ b/homeassistant/components/verisure/switch.py
@@ -99,4 +99,4 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch
)
self._state = state
self._change_timestamp = monotonic()
- await self.coordinator.async_request_refresh()
+ self.async_write_ha_state()
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index dddf7857545..003d93ed603 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -3,29 +3,17 @@
import logging
from pyvesync import VeSync
+from pyvesync.utils.errors import VeSyncLoginError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_USERNAME,
- EVENT_LOGGING_CHANGED,
- Platform,
-)
-from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .common import async_generate_device_list
-from .const import (
- DOMAIN,
- SERVICE_UPDATE_DEVS,
- VS_COORDINATOR,
- VS_DEVICES,
- VS_DISCOVERY,
- VS_LISTENERS,
- VS_MANAGER,
-)
+from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
PLATFORMS = [
@@ -53,14 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
username=username,
password=password,
time_zone=time_zone,
- debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG,
- redact=True,
+ session=async_get_clientsession(hass),
)
-
- login = await hass.async_add_executor_job(manager.login)
-
- if not login:
- raise ConfigEntryAuthFailed
+ try:
+ await manager.login()
+ except VeSyncLoginError as err:
+ raise ConfigEntryAuthFailed from err
hass.data[DOMAIN] = {}
hass.data[DOMAIN][VS_MANAGER] = manager
@@ -69,37 +55,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# Store coordinator at domain level since only single integration instance is permitted.
hass.data[DOMAIN][VS_COORDINATOR] = coordinator
-
- hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager)
+ await manager.update()
+ await manager.check_firmware()
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
- @callback
- def _async_handle_logging_changed(_event: Event) -> None:
- """Handle when the logging level changes."""
- manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG
-
- cleanup = hass.bus.async_listen(
- EVENT_LOGGING_CHANGED, _async_handle_logging_changed
- )
-
- hass.data[DOMAIN][VS_LISTENERS] = cleanup
-
async def async_new_device_discovery(service: ServiceCall) -> None:
- """Discover if new devices should be added."""
+ """Discover and add new devices."""
manager = hass.data[DOMAIN][VS_MANAGER]
- devices = hass.data[DOMAIN][VS_DEVICES]
+ known_devices = list(manager.devices)
+ await manager.get_devices()
+ new_devices = [
+ device for device in manager.devices if device not in known_devices
+ ]
- new_devices = await async_generate_device_list(hass, manager)
-
- device_set = set(new_devices)
- new_devices = list(device_set.difference(devices))
- if new_devices and devices:
- devices.extend(new_devices)
- async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices)
- return
- if new_devices and not devices:
- devices.extend(new_devices)
+ if new_devices:
+ async_dispatcher_send(hass, "vesync_new_devices", new_devices)
hass.services.async_register(
DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery
@@ -110,7 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- hass.data[DOMAIN][VS_LISTENERS]()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data.pop(DOMAIN)
diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py
index 7b6f14e04dc..7b72c80ff85 100644
--- a/homeassistant/components/vesync/binary_sensor.py
+++ b/homeassistant/components/vesync/binary_sensor.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import rgetattr
-from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -31,20 +31,25 @@ class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription):
"""A class that describes custom binary sensor entities."""
is_on: Callable[[VeSyncBaseDevice], bool]
+ exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True
SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = (
VeSyncBinarySensorEntityDescription(
key="water_lacks",
translation_key="water_lacks",
- is_on=lambda device: device.water_lacks,
+ is_on=lambda device: device.state.water_lacks,
device_class=BinarySensorDeviceClass.PROBLEM,
+ exists_fn=lambda device: rgetattr(device, "state.water_lacks") is not None,
),
VeSyncBinarySensorEntityDescription(
key="details.water_tank_lifted",
translation_key="water_tank_lifted",
- is_on=lambda device: device.details["water_tank_lifted"],
+ is_on=lambda device: device.state.water_tank_lifted,
device_class=BinarySensorDeviceClass.PROBLEM,
+ exists_fn=(
+ lambda device: rgetattr(device, "state.water_tank_lifted") is not None
+ ),
),
)
@@ -67,7 +72,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -78,7 +85,7 @@ def _setup_entities(devices, async_add_entities, coordinator):
VeSyncBinarySensor(dev, description, coordinator)
for dev in devices
for description in SENSOR_DESCRIPTIONS
- if rgetattr(dev, description.key) is not None
+ if description.exists_fn(dev)
),
)
diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py
index 6dda6800c62..eaad7aded39 100644
--- a/homeassistant/components/vesync/common.py
+++ b/homeassistant/components/vesync/common.py
@@ -2,14 +2,12 @@
import logging
-from pyvesync import VeSync
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
-from pyvesync.vesyncoutlet import VeSyncOutlet
-from pyvesync.vesyncswitch import VeSyncWallSwitch
-
-from homeassistant.core import HomeAssistant
-
-from .const import VeSyncFanDevice, VeSyncHumidifierDevice
+from pyvesync.base_devices import VeSyncHumidifier
+from pyvesync.base_devices.fan_base import VeSyncFanBase
+from pyvesync.base_devices.outlet_base import VeSyncOutlet
+from pyvesync.base_devices.purifier_base import VeSyncPurifier
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.devices.vesyncswitch import VeSyncWallSwitch
_LOGGER = logging.getLogger(__name__)
@@ -36,32 +34,16 @@ def rgetattr(obj: object, attr: str):
return obj
-async def async_generate_device_list(
- hass: HomeAssistant, manager: VeSync
-) -> list[VeSyncBaseDevice]:
- """Assign devices to proper component."""
- devices: list[VeSyncBaseDevice] = []
-
- await hass.async_add_executor_job(manager.update)
-
- devices.extend(manager.fans)
- devices.extend(manager.bulbs)
- devices.extend(manager.outlets)
- devices.extend(manager.switches)
-
- return devices
-
-
def is_humidifier(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents a humidifier."""
- return isinstance(device, VeSyncHumidifierDevice)
+ return isinstance(device, VeSyncHumidifier)
def is_fan(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents a fan."""
- return isinstance(device, VeSyncFanDevice)
+ return isinstance(device, VeSyncFanBase)
def is_outlet(device: VeSyncBaseDevice) -> bool:
@@ -74,3 +56,9 @@ def is_wall_switch(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents a wall switch, note this doessn't include dimming switches."""
return isinstance(device, VeSyncWallSwitch)
+
+
+def is_purifier(device: VeSyncBaseDevice) -> bool:
+ """Check if the device represents an air purifier."""
+
+ return isinstance(device, VeSyncPurifier)
diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py
index e5537d8fcc9..bc1a47be712 100644
--- a/homeassistant/components/vesync/config_flow.py
+++ b/homeassistant/components/vesync/config_flow.py
@@ -1,18 +1,23 @@
"""Config flow utilities."""
from collections.abc import Mapping
+import logging
from typing import Any
from pyvesync import VeSync
+from pyvesync.utils.errors import VeSyncError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
@@ -49,9 +54,18 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN):
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
- manager = VeSync(username, password)
- login = await self.hass.async_add_executor_job(manager.login)
- if not login:
+ time_zone = str(self.hass.config.time_zone)
+
+ manager = VeSync(
+ username,
+ password,
+ time_zone=time_zone,
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await manager.login()
+ except VeSyncError as e:
+ _LOGGER.error("VeSync login failed: %s", str(e))
return self._show_form(errors={"base": "invalid_auth"})
return self.async_create_entry(
@@ -74,17 +88,33 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN):
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
- manager = VeSync(username, password)
- login = await self.hass.async_add_executor_job(manager.login)
- if login:
- return self.async_update_reload_and_abort(
- self._get_reauth_entry(),
- data_updates={
- CONF_USERNAME: username,
- CONF_PASSWORD: password,
- },
+ time_zone = str(self.hass.config.time_zone)
+
+ manager = VeSync(
+ username,
+ password,
+ time_zone=time_zone,
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await manager.login()
+ except VeSyncError as e:
+ _LOGGER.error("VeSync login failed: %s", str(e))
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=DATA_SCHEMA,
+ description_placeholders={"name": "VeSync"},
+ errors={"base": "invalid_auth"},
)
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password,
+ },
+ )
+
return self.async_show_form(
step_id="reauth_confirm",
data_schema=DATA_SCHEMA,
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index 6d818b463d8..df7a45e3034 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -1,18 +1,11 @@
"""Constants for VeSync Component."""
-from pyvesync.vesyncfan import (
- VeSyncAir131,
- VeSyncAirBaseV2,
- VeSyncAirBypass,
- VeSyncHumid200300S,
- VeSyncSuperior6000S,
-)
-
DOMAIN = "vesync"
VS_DISCOVERY = "vesync_discovery_{}"
SERVICE_UPDATE_DEVS = "update_devices"
UPDATE_INTERVAL = 60
+UPDATE_INTERVAL_ENERGY = 60 * 60 * 6
"""
Update interval for DataCoordinator.
@@ -24,6 +17,9 @@ total would be 2880.
Using 30 seconds interval gives 8640 for 3 devices which
exceeds the quota of 7700.
+
+Energy history is weekly/monthly/yearly and can be updated a lot more infrequently,
+in this case every 6 hours.
"""
VS_DEVICES = "devices"
VS_COORDINATOR = "coordinator"
@@ -57,87 +53,14 @@ NIGHT_LIGHT_LEVEL_BRIGHT = "bright"
NIGHT_LIGHT_LEVEL_DIM = "dim"
NIGHT_LIGHT_LEVEL_OFF = "off"
-FAN_NIGHT_LIGHT_LEVEL_DIM = "dim"
-FAN_NIGHT_LIGHT_LEVEL_OFF = "off"
-FAN_NIGHT_LIGHT_LEVEL_ON = "on"
-
HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright"
HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
-VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S
-"""Humidifier device types"""
+OUTLET_NIGHT_LIGHT_LEVEL_AUTO = "auto"
+OUTLET_NIGHT_LIGHT_LEVEL_OFF = "off"
+OUTLET_NIGHT_LIGHT_LEVEL_ON = "on"
-VeSyncFanDevice = VeSyncAirBypass | VeSyncAirBypass | VeSyncAirBaseV2 | VeSyncAir131
-"""Fan device types"""
-
-
-DEV_TYPE_TO_HA = {
- "wifi-switch-1.3": "outlet",
- "ESW03-USA": "outlet",
- "ESW01-EU": "outlet",
- "ESW15-USA": "outlet",
- "ESWL01": "switch",
- "ESWL03": "switch",
- "ESO15-TB": "outlet",
- "LV-PUR131S": "fan",
- "Core200S": "fan",
- "Core300S": "fan",
- "Core400S": "fan",
- "Core600S": "fan",
- "EverestAir": "fan",
- "Vital200S": "fan",
- "Vital100S": "fan",
- "SmartTowerFan": "fan",
- "ESD16": "walldimmer",
- "ESWD16": "walldimmer",
- "ESL100": "bulb-dimmable",
- "ESL100CW": "bulb-tunable-white",
-}
-
-SKU_TO_BASE_DEVICE = {
- # Air Purifiers
- "LV-PUR131S": "LV-PUR131S",
- "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S
- "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S
- "Core200S": "Core200S",
- "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S
- "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S
- "Core300S": "Core300S",
- "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S
- "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S
- "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S
- "Core400S": "Core400S",
- "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S
- "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S
- "LAP-C401S-WAAA": "Core400S", # Alt ID Model Core400S
- "Core600S": "Core600S",
- "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S
- "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S
- "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S,
- "Vital200S": "Vital200S",
- "LAP-V201S-AASR": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201S-WJP": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201S-AEUR": "Vital200S", # Alt ID Model Vital200S
- "LAP-V201S-AUSR": "Vital200S", # Alt ID Model Vital200S
- "Vital100S": "Vital100S",
- "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S
- "LAP-V102S-AASR": "Vital100S", # Alt ID Model Vital100S
- "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S
- "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S
- "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S
- "LAP-V102S-WJP": "Vital100S", # Alt ID Model Vital100S
- "EverestAir": "EverestAir",
- "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir
- "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir
- "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir
- "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir
- "SmartTowerFan": "SmartTowerFan",
- "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan
- "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan
- "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan
- "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan
-}
+PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
+PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
+PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on"
diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py
index e8c8396bfb4..a857d337c8d 100644
--- a/homeassistant/components/vesync/coordinator.py
+++ b/homeassistant/components/vesync/coordinator.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
from pyvesync import VeSync
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import UPDATE_INTERVAL
+from .const import UPDATE_INTERVAL, UPDATE_INTERVAL_ENERGY
_LOGGER = logging.getLogger(__name__)
@@ -20,6 +20,7 @@ class VeSyncDataCoordinator(DataUpdateCoordinator[None]):
"""Class representing data coordinator for VeSync devices."""
config_entry: ConfigEntry
+ update_time: datetime | None = None
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync
@@ -35,15 +36,21 @@ class VeSyncDataCoordinator(DataUpdateCoordinator[None]):
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
+ def should_update_energy(self) -> bool:
+ """Test if specified update interval has been exceeded."""
+ if self.update_time is None:
+ return True
+
+ return datetime.now() - self.update_time >= timedelta(
+ seconds=UPDATE_INTERVAL_ENERGY
+ )
+
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
- return await self.hass.async_add_executor_job(self.update_data_all)
+ await self._manager.update_all_devices()
- def update_data_all(self) -> None:
- """Update all the devices."""
-
- # Using `update_all_devices` instead of `update` to avoid fetching device list every time.
- self._manager.update_all_devices()
- # Vesync updates energy on applicable devices every 6 hours
- self._manager.update_energy()
+ if self.should_update_energy():
+ self.update_time = datetime.now()
+ for outlet in self._manager.devices.outlets:
+ await outlet.update_energy()
diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py
index e1c092b1e32..7ca8f7789bd 100644
--- a/homeassistant/components/vesync/diagnostics.py
+++ b/homeassistant/components/vesync/diagnostics.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from pyvesync import VeSync
@@ -13,7 +13,6 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN, VS_MANAGER
-from .entity import VeSyncBaseDevice
KEYS_TO_REDACT = {"manager", "uuid", "mac_id"}
@@ -26,18 +25,16 @@ async def async_get_config_entry_diagnostics(
return {
DOMAIN: {
- "bulb_count": len(manager.bulbs),
- "fan_count": len(manager.fans),
- "outlets_count": len(manager.outlets),
- "switch_count": len(manager.switches),
+ "Total Device Count": len(manager.devices),
+ "bulb_count": len(manager.devices.bulbs),
+ "fan_count": len(manager.devices.fans),
+ "humidifers_count": len(manager.devices.humidifiers),
+ "air_purifiers": len(manager.devices.air_purifiers),
+ "outlets_count": len(manager.devices.outlets),
+ "switch_count": len(manager.devices.switches),
"timezone": manager.time_zone,
},
- "devices": {
- "bulbs": [_redact_device_values(device) for device in manager.bulbs],
- "fans": [_redact_device_values(device) for device in manager.fans],
- "outlets": [_redact_device_values(device) for device in manager.outlets],
- "switches": [_redact_device_values(device) for device in manager.switches],
- },
+ "devices": [_redact_device_values(device) for device in manager.devices],
}
@@ -46,11 +43,24 @@ async def async_get_device_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
manager: VeSync = hass.data[DOMAIN][VS_MANAGER]
- device_dict = _build_device_dict(manager)
vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN)
+ def get_vesync_unique_id(dev: Any) -> str:
+ """Return the unique ID for a VeSync device."""
+ cid = getattr(dev, "cid", None)
+ sub_device_no = getattr(dev, "sub_device_no", None)
+ if cid is None:
+ return ""
+ if isinstance(sub_device_no, int):
+ return f"{cid}{sub_device_no!s}"
+ return str(cid)
+
+ vesync_device = next(
+ dev for dev in manager.devices if get_vesync_unique_id(dev) == vesync_device_id
+ )
+
# Base device information, without sensitive information.
- data = _redact_device_values(device_dict[vesync_device_id])
+ data = _redact_device_values(vesync_device)
data["home_assistant"] = {
"name": device.name,
@@ -76,7 +86,7 @@ async def async_get_device_diagnostics(
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
- data["home_assistant"]["entities"].append(
+ cast(dict[str, Any], data["home_assistant"])["entities"].append(
{
"domain": entity_entry.domain,
"entity_id": entity_entry.entity_id,
@@ -97,21 +107,19 @@ async def async_get_device_diagnostics(
return data
-def _build_device_dict(manager: VeSync) -> dict:
- """Build a dictionary of ALL VeSync devices."""
- device_dict = {x.cid: x for x in manager.switches}
- device_dict.update({x.cid: x for x in manager.fans})
- device_dict.update({x.cid: x for x in manager.outlets})
- device_dict.update({x.cid: x for x in manager.bulbs})
- return device_dict
-
-
-def _redact_device_values(device: VeSyncBaseDevice) -> dict:
+def _redact_device_values(obj: object) -> dict[str, str | dict[str, Any]]:
"""Rebuild and redact values of a VeSync device."""
- data = {}
- for key, item in device.__dict__.items():
- if key not in KEYS_TO_REDACT:
- data[key] = item
+ data: dict[str, str | dict[str, Any]] = {}
+ for key in dir(obj):
+ if key.startswith("_"):
+ # Skip private attributes
+ continue
+ if callable(getattr(obj, key)):
+ data[key] = "Method"
+ elif key == "state":
+ data[key] = _redact_device_values(getattr(obj, key))
+ elif key not in KEYS_TO_REDACT:
+ data[key] = getattr(obj, key)
else:
data[key] = REDACTED
diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py
index 3aa7b008cc5..023e1a10c55 100644
--- a/homeassistant/components/vesync/entity.py
+++ b/homeassistant/components/vesync/entity.py
@@ -1,6 +1,6 @@
"""Common entity for VeSync Component."""
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -35,7 +35,7 @@ class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]):
@property
def available(self) -> bool:
"""Return True if device is available."""
- return self.device.connection_status == "online"
+ return self.device.state.connection_status == "online"
@property
def device_info(self) -> DeviceInfo:
diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py
index 5b0197606ae..23edf1660a0 100644
--- a/homeassistant/components/vesync/fan.py
+++ b/homeassistant/components/vesync/fan.py
@@ -3,10 +3,9 @@
from __future__ import annotations
import logging
-import math
from typing import Any
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
@@ -15,15 +14,13 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
- percentage_to_ranged_value,
- ranged_value_to_percentage,
+ ordered_list_item_to_percentage,
+ percentage_to_ordered_list_item,
)
-from homeassistant.util.scaling import int_states_in_range
-from .common import is_fan
+from .common import is_fan, is_purifier
from .const import (
DOMAIN,
- SKU_TO_BASE_DEVICE,
VS_COORDINATOR,
VS_DEVICES,
VS_DISCOVERY,
@@ -35,24 +32,13 @@ from .const import (
VS_FAN_MODE_PRESET_LIST_HA,
VS_FAN_MODE_SLEEP,
VS_FAN_MODE_TURBO,
+ VS_MANAGER,
)
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
-SPEED_RANGE = { # off is not included
- "LV-PUR131S": (1, 3),
- "Core200S": (1, 3),
- "Core300S": (1, 3),
- "Core400S": (1, 4),
- "Core600S": (1, 4),
- "EverestAir": (1, 3),
- "Vital200S": (1, 4),
- "Vital100S": (1, 4),
- "SmartTowerFan": (1, 13),
-}
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -72,7 +58,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -83,7 +71,11 @@ def _setup_entities(
):
"""Check if device is fan and add entity."""
- async_add_entities(VeSyncFanHA(dev, coordinator) for dev in devices if is_fan(dev))
+ async_add_entities(
+ VeSyncFanHA(dev, coordinator)
+ for dev in devices
+ if is_fan(dev) or is_purifier(dev)
+ )
class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
@@ -101,26 +93,25 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
@property
def is_on(self) -> bool:
"""Return True if device is on."""
- return self.device.device_status == "on"
+ return self.device.state.device_status == "on"
@property
def percentage(self) -> int | None:
- """Return the current speed."""
- if (
- self.device.mode == VS_FAN_MODE_MANUAL
- and (current_level := self.device.fan_level) is not None
- ):
- return ranged_value_to_percentage(
- SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level
+ """Return the currently set speed."""
+
+ current_level = self.device.state.fan_level
+ if self.device.state.mode == VS_FAN_MODE_MANUAL and current_level is not None:
+ if current_level == 0:
+ return 0
+ return ordered_list_item_to_percentage(
+ self.device.fan_levels, current_level
)
return None
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
- return int_states_in_range(
- SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]]
- )
+ return len(self.device.fan_levels)
@property
def preset_modes(self) -> list[str]:
@@ -128,7 +119,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
if hasattr(self.device, "modes"):
return sorted(
[
- mode
+ mode.value
for mode in self.device.modes
if mode in VS_FAN_MODE_PRESET_LIST_HA
]
@@ -138,8 +129,8 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
@property
def preset_mode(self) -> str | None:
"""Get the current preset mode."""
- if self.device.mode in VS_FAN_MODE_PRESET_LIST_HA:
- return self.device.mode
+ if self.device.state.mode in VS_FAN_MODE_PRESET_LIST_HA:
+ return self.device.state.mode
return None
@property
@@ -147,57 +138,69 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
"""Return the state attributes of the fan."""
attr = {}
- if hasattr(self.device, "active_time"):
- attr["active_time"] = self.device.active_time
+ if hasattr(self.device.state, "active_time"):
+ attr["active_time"] = self.device.state.active_time
- if hasattr(self.device, "screen_status"):
- attr["screen_status"] = self.device.screen_status
+ if hasattr(self.device.state, "display_status"):
+ attr["display_status"] = getattr(
+ self.device.state.display_status, "value", None
+ )
- if hasattr(self.device, "child_lock"):
- attr["child_lock"] = self.device.child_lock
+ if hasattr(self.device.state, "child_lock"):
+ attr["child_lock"] = self.device.state.child_lock
- if hasattr(self.device, "night_light"):
- attr["night_light"] = self.device.night_light
+ if hasattr(self.device.state, "nightlight_status"):
+ attr["night_light"] = self.device.state.nightlight_status
- if hasattr(self.device, "mode"):
- attr["mode"] = self.device.mode
+ if hasattr(self.device.state, "mode"):
+ attr["mode"] = self.device.state.mode
return attr
- def set_percentage(self, percentage: int) -> None:
+ async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the device.
If percentage is 0, turn off the fan. Otherwise, ensure the fan is on,
set manual mode if needed, and set the speed.
"""
- device_type = SKU_TO_BASE_DEVICE[self.device.device_type]
- speed_range = SPEED_RANGE[device_type]
-
if percentage == 0:
# Turning off is a special case: do not set speed or mode
- if not self.device.turn_off():
- raise HomeAssistantError("An error occurred while turning off.")
+ if not await self.device.turn_off():
+ raise HomeAssistantError(
+ "An error occurred while turning off: "
+ + self.device.last_response.message
+ )
self.schedule_update_ha_state()
return
# If the fan is off, turn it on first
if not self.device.is_on:
- if not self.device.turn_on():
- raise HomeAssistantError("An error occurred while turning on.")
+ if not await self.device.turn_on():
+ raise HomeAssistantError(
+ "An error occurred while turning on: "
+ + self.device.last_response.message
+ )
# Switch to manual mode if not already set
- if self.device.mode != VS_FAN_MODE_MANUAL:
- if not self.device.manual_mode():
- raise HomeAssistantError("An error occurred while setting manual mode.")
+ if self.device.state.mode != VS_FAN_MODE_MANUAL:
+ if not await self.device.set_manual_mode():
+ raise HomeAssistantError(
+ "An error occurred while setting manual mode."
+ + self.device.last_response.message
+ )
# Calculate the speed level and set it
- speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage))
- if not self.device.change_fan_speed(speed_level):
- raise HomeAssistantError("An error occurred while changing fan speed.")
+ if not await self.device.set_fan_speed(
+ percentage_to_ordered_list_item(self.device.fan_levels, percentage)
+ ):
+ raise HomeAssistantError(
+ "An error occurred while changing fan speed: "
+ + self.device.last_response.message
+ )
self.schedule_update_ha_state()
- def set_preset_mode(self, preset_mode: str) -> None:
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of device."""
if preset_mode not in VS_FAN_MODE_PRESET_LIST_HA:
raise ValueError(
@@ -206,26 +209,26 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
)
if not self.device.is_on:
- self.device.turn_on()
+ await self.device.turn_on()
if preset_mode == VS_FAN_MODE_AUTO:
- success = self.device.auto_mode()
+ success = await self.device.auto_mode()
elif preset_mode == VS_FAN_MODE_SLEEP:
- success = self.device.sleep_mode()
+ success = await self.device.sleep_mode()
elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP:
- success = self.device.advanced_sleep_mode()
+ success = await self.device.advanced_sleep_mode()
elif preset_mode == VS_FAN_MODE_PET:
- success = self.device.pet_mode()
+ success = await self.device.pet_mode()
elif preset_mode == VS_FAN_MODE_TURBO:
- success = self.device.turbo_mode()
+ success = await self.device.turbo_mode()
elif preset_mode == VS_FAN_MODE_NORMAL:
- success = self.device.normal_mode()
+ success = await self.device.normal_mode()
if not success:
- raise HomeAssistantError("An error occurred while setting preset mode.")
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
- def turn_on(
+ async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
@@ -233,15 +236,15 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
) -> None:
"""Turn the device on."""
if preset_mode:
- self.set_preset_mode(preset_mode)
+ await self.async_set_preset_mode(preset_mode)
return
if percentage is None:
percentage = 50
- self.set_percentage(percentage)
+ await self.async_set_percentage(percentage)
- def turn_off(self, **kwargs: Any) -> None:
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- success = self.device.turn_off()
+ success = await self.device.turn_off()
if not success:
- raise HomeAssistantError("An error occurred while turning off.")
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py
index 9a98a39aa8c..8edb405121a 100644
--- a/homeassistant/components/vesync/humidifier.py
+++ b/homeassistant/components/vesync/humidifier.py
@@ -3,7 +3,7 @@
import logging
from typing import Any
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.humidifier import (
MODE_AUTO,
@@ -18,7 +18,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .common import is_humidifier
from .const import (
DOMAIN,
VS_COORDINATOR,
@@ -28,7 +27,7 @@ from .const import (
VS_HUMIDIFIER_MODE_HUMIDITY,
VS_HUMIDIFIER_MODE_MANUAL,
VS_HUMIDIFIER_MODE_SLEEP,
- VeSyncHumidifierDevice,
+ VS_MANAGER,
)
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -36,9 +35,6 @@ from .entity import VeSyncBaseEntity
_LOGGER = logging.getLogger(__name__)
-MIN_HUMIDITY = 30
-MAX_HUMIDITY = 80
-
VS_TO_HA_MODE_MAP = {
VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO,
VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO,
@@ -65,7 +61,11 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices.humidifiers,
+ async_add_entities,
+ coordinator,
+ )
@callback
@@ -75,9 +75,7 @@ def _setup_entities(
coordinator: VeSyncDataCoordinator,
):
"""Add humidifier entities."""
- async_add_entities(
- VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev)
- )
+ async_add_entities(VeSyncHumidifierHA(dev, coordinator) for dev in devices)
def _get_ha_mode(vs_mode: str) -> str | None:
@@ -93,12 +91,8 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
# The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name
_attr_name = None
- _attr_max_humidity = MAX_HUMIDITY
- _attr_min_humidity = MIN_HUMIDITY
_attr_supported_features = HumidifierEntityFeature.MODES
- device: VeSyncHumidifierDevice
-
def __init__(
self,
device: VeSyncBaseDevice,
@@ -113,6 +107,8 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
self._ha_to_vs_mode_map: dict[str, str] = {}
self._available_modes: list[str] = []
+ self._attr_max_humidity = max(device.target_minmax)
+ self._attr_min_humidity = min(device.target_minmax)
# Populate maps once.
for vs_mode in self.device.mist_modes:
@@ -134,37 +130,39 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
@property
def current_humidity(self) -> int:
"""Return the current humidity."""
- return self.device.humidity
+ return self.device.state.humidity
@property
def target_humidity(self) -> int:
"""Return the humidity we try to reach."""
- return self.device.auto_humidity
+ return self.device.state.auto_humidity
@property
def mode(self) -> str | None:
"""Get the current preset mode."""
- return None if self.device.mode is None else _get_ha_mode(self.device.mode)
+ return (
+ None
+ if self.device.state.mode is None
+ else _get_ha_mode(self.device.state.mode)
+ )
- def set_humidity(self, humidity: int) -> None:
+ async def async_set_humidity(self, humidity: int) -> None:
"""Set the target humidity of the device."""
- if not self.device.set_humidity(humidity):
- raise HomeAssistantError(
- f"An error occurred while setting humidity {humidity}."
- )
+ if not await self.device.set_humidity(humidity):
+ raise HomeAssistantError(self.device.last_response.message)
- def set_mode(self, mode: str) -> None:
+ async def async_set_mode(self, mode: str) -> None:
"""Set the mode of the device."""
if mode not in self.available_modes:
raise HomeAssistantError(
- f"{mode} is not one of the valid available modes: {self.available_modes}"
+ f"Invalid mode {mode}. Available modes: {self.available_modes}"
)
- if not self.device.set_humidity_mode(self._get_vs_mode(mode)):
- raise HomeAssistantError(f"An error occurred while setting mode {mode}.")
+ if not await self.device.set_humidity_mode(self._get_vs_mode(mode)):
+ raise HomeAssistantError(self.device.last_response.message)
if mode == MODE_SLEEP:
# We successfully changed the mode. Consider it a success even if display operation fails.
- self.device.set_display(False)
+ await self.device.toggle_display(False)
# Changing mode while humidifier is off actually turns it on, as per the app. But
# the library does not seem to update the device_status. It is also possible that
@@ -172,23 +170,23 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
# updated.
self.schedule_update_ha_state(force_refresh=True)
- def turn_on(self, **kwargs: Any) -> None:
+ async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
- success = self.device.turn_on()
+ success = await self.device.turn_on()
if not success:
- raise HomeAssistantError("An error occurred while turning on.")
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
- def turn_off(self, **kwargs: Any) -> None:
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- success = self.device.turn_off()
+ success = await self.device.turn_off()
if not success:
- raise HomeAssistantError("An error occurred while turning off.")
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
@property
def is_on(self) -> bool:
"""Return True if device is on."""
- return self.device.device_status == "on"
+ return self.device.state.device_status == "on"
diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py
index 887400b2cf0..1e5ce3027cf 100644
--- a/homeassistant/components/vesync/light.py
+++ b/homeassistant/components/vesync/light.py
@@ -3,7 +3,9 @@
import logging
from typing import Any
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.bulb_base import VeSyncBulb
+from pyvesync.base_devices.switch_base import VeSyncSwitch
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -17,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
-from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -44,7 +46,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -56,10 +60,13 @@ def _setup_entities(
"""Check if device is a light and add entity."""
entities: list[VeSyncBaseLightHA] = []
for dev in devices:
- if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"):
+ if isinstance(dev, VeSyncBulb):
+ if dev.supports_color_temp:
+ entities.append(VeSyncTunableWhiteLightHA(dev, coordinator))
+ elif dev.supports_brightness:
+ entities.append(VeSyncDimmableLightHA(dev, coordinator))
+ elif isinstance(dev, VeSyncSwitch) and dev.supports_dimmable:
entities.append(VeSyncDimmableLightHA(dev, coordinator))
- elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",):
- entities.append(VeSyncTunableWhiteLightHA(dev, coordinator))
async_add_entities(entities, update_before_add=True)
@@ -72,13 +79,13 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
@property
def is_on(self) -> bool:
"""Return True if device is on."""
- return self.device.device_status == "on"
+ return self.device.state.device_status == "on"
@property
def brightness(self) -> int:
"""Get light brightness."""
# get value from pyvesync library api,
- result = self.device.brightness
+ result = self.device.state.brightness
try:
# check for validity of brightness value received
brightness_value = int(result)
@@ -92,7 +99,7 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
# convert percent brightness to ha expected range
return round((max(1, brightness_value) / 100) * 255)
- def turn_on(self, **kwargs: Any) -> None:
+ async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
attribute_adjustment_only = False
# set white temperature
@@ -112,7 +119,7 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
# ensure value between 0-100
color_temp = max(0, min(color_temp, 100))
# call pyvesync library api method to set color_temp
- self.device.set_color_temp(color_temp)
+ await self.device.set_color_temp(color_temp)
# flag attribute_adjustment_only, so it doesn't turn_on the device redundantly
attribute_adjustment_only = True
# set brightness level
@@ -129,7 +136,7 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
# ensure value between 1-100
brightness = max(1, min(brightness, 100))
# call pyvesync library api method to set brightness
- self.device.set_brightness(brightness)
+ await self.device.set_brightness(brightness)
# flag attribute_adjustment_only, so it doesn't
# turn_on the device redundantly
attribute_adjustment_only = True
@@ -137,11 +144,11 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity):
if attribute_adjustment_only:
return
# send turn_on command to pyvesync api
- self.device.turn_on()
+ await self.device.turn_on()
- def turn_off(self, **kwargs: Any) -> None:
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
- self.device.turn_off()
+ await self.device.turn_off()
class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity):
@@ -162,8 +169,9 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity):
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature value in Kelvin."""
- # get value from pyvesync library api,
- result = self.device.color_temp_pct
+ # get value from pyvesync library api
+ # pyvesync v3 provides BulbState.color_temp_kelvin() - possible to use that instead?
+ result = self.device.state.color_temp
try:
# check for validity of brightness value received
color_temp_value = int(result)
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index 571c6ee0036..8749dd956ff 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -6,11 +6,12 @@
"@webdjoe",
"@thegardenmonkey",
"@cdnninja",
- "@iprak"
+ "@iprak",
+ "@sapuseven"
],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
- "loggers": ["pyvesync.vesync"],
- "requirements": ["pyvesync==2.1.18"]
+ "loggers": ["pyvesync"],
+ "requirements": ["pyvesync==3.1.0"]
}
diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py
index 707dd6ab30e..82444ab1246 100644
--- a/homeassistant/components/vesync/number.py
+++ b/homeassistant/components/vesync/number.py
@@ -1,10 +1,10 @@
"""Support for VeSync numeric entities."""
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.number import (
NumberEntity,
@@ -13,11 +13,12 @@ from homeassistant.components.number import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import is_humidifier
-from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -28,22 +29,24 @@ _LOGGER = logging.getLogger(__name__)
class VeSyncNumberEntityDescription(NumberEntityDescription):
"""Class to describe a Vesync number entity."""
- exists_fn: Callable[[VeSyncBaseDevice], bool]
+ exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True
value_fn: Callable[[VeSyncBaseDevice], float]
- set_value_fn: Callable[[VeSyncBaseDevice, float], bool]
+ native_min_value_fn: Callable[[VeSyncBaseDevice], float]
+ native_max_value_fn: Callable[[VeSyncBaseDevice], float]
+ set_value_fn: Callable[[VeSyncBaseDevice, float], Awaitable[bool]]
NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [
VeSyncNumberEntityDescription(
key="mist_level",
translation_key="mist_level",
- native_min_value=1,
- native_max_value=9,
+ native_min_value_fn=lambda device: min(device.mist_levels),
+ native_max_value_fn=lambda device: max(device.mist_levels),
native_step=1,
mode=NumberMode.SLIDER,
exists_fn=is_humidifier,
set_value_fn=lambda device, value: device.set_mist_level(value),
- value_fn=lambda device: device.mist_level,
+ value_fn=lambda device: device.state.mist_level,
)
]
@@ -66,7 +69,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -106,9 +111,18 @@ class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity):
"""Return the value reported by the number."""
return self.entity_description.value_fn(self.device)
+ @property
+ def native_min_value(self) -> float:
+ """Return the value reported by the number."""
+ return self.entity_description.native_min_value_fn(self.device)
+
+ @property
+ def native_max_value(self) -> float:
+ """Return the value reported by the number."""
+ return self.entity_description.native_max_value_fn(self.device)
+
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
- if await self.hass.async_add_executor_job(
- self.entity_description.set_value_fn, self.device, value
- ):
- await self.coordinator.async_request_refresh()
+ if not await self.entity_description.set_value_fn(self.device, value):
+ raise HomeAssistantError(self.device.last_response.message)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py
index a9d2e1b533a..e34d13babf0 100644
--- a/homeassistant/components/vesync/select.py
+++ b/homeassistant/components/vesync/select.py
@@ -1,29 +1,34 @@
"""Support for VeSync numeric entities."""
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .common import rgetattr
+from .common import is_humidifier, is_outlet, is_purifier
from .const import (
DOMAIN,
- FAN_NIGHT_LIGHT_LEVEL_DIM,
- FAN_NIGHT_LIGHT_LEVEL_OFF,
- FAN_NIGHT_LIGHT_LEVEL_ON,
HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT,
HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM,
HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF,
+ OUTLET_NIGHT_LIGHT_LEVEL_AUTO,
+ OUTLET_NIGHT_LIGHT_LEVEL_OFF,
+ OUTLET_NIGHT_LIGHT_LEVEL_ON,
+ PURIFIER_NIGHT_LIGHT_LEVEL_DIM,
+ PURIFIER_NIGHT_LIGHT_LEVEL_OFF,
+ PURIFIER_NIGHT_LIGHT_LEVEL_ON,
VS_COORDINATOR,
VS_DEVICES,
VS_DISCOVERY,
+ VS_MANAGER,
)
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -47,7 +52,7 @@ class VeSyncSelectEntityDescription(SelectEntityDescription):
exists_fn: Callable[[VeSyncBaseDevice], bool]
current_option_fn: Callable[[VeSyncBaseDevice], str]
- select_option_fn: Callable[[VeSyncBaseDevice, str], bool]
+ select_option_fn: Callable[[VeSyncBaseDevice, str], Awaitable[bool]]
SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [
@@ -57,34 +62,45 @@ SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [
translation_key="night_light_level",
options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()),
icon="mdi:brightness-6",
- exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"),
+ exists_fn=lambda device: is_humidifier(device) and device.supports_nightlight,
# The select_option service framework ensures that only options specified are
# accepted. ServiceValidationError gets raised for invalid value.
- select_option_fn=lambda device, value: device.set_night_light_brightness(
+ select_option_fn=lambda device, value: device.set_nightlight_brightness(
HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0)
),
# Reporting "off" as the choice for unhandled level.
current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(
- device.details.get("night_light_brightness"),
+ device.state.nightlight_brightness,
HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF,
),
),
- # night_light for fan devices based on pyvesync.VeSyncAirBypass
+ # night_light for air purifiers
VeSyncSelectEntityDescription(
key="night_light_level",
translation_key="night_light_level",
options=[
- FAN_NIGHT_LIGHT_LEVEL_OFF,
- FAN_NIGHT_LIGHT_LEVEL_DIM,
- FAN_NIGHT_LIGHT_LEVEL_ON,
+ PURIFIER_NIGHT_LIGHT_LEVEL_OFF,
+ PURIFIER_NIGHT_LIGHT_LEVEL_DIM,
+ PURIFIER_NIGHT_LIGHT_LEVEL_ON,
],
icon="mdi:brightness-6",
- exists_fn=lambda device: rgetattr(device, "set_night_light"),
- select_option_fn=lambda device, value: device.set_night_light(value),
- current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(
- device.details.get("night_light"),
- FAN_NIGHT_LIGHT_LEVEL_OFF,
- ),
+ exists_fn=lambda device: is_purifier(device) and device.supports_nightlight,
+ select_option_fn=lambda device, value: device.set_nightlight_mode(value),
+ current_option_fn=lambda device: device.state.nightlight_status,
+ ),
+ # night_light for outlets
+ VeSyncSelectEntityDescription(
+ key="night_light_level",
+ translation_key="night_light_level",
+ options=[
+ OUTLET_NIGHT_LIGHT_LEVEL_OFF,
+ OUTLET_NIGHT_LIGHT_LEVEL_ON,
+ OUTLET_NIGHT_LIGHT_LEVEL_AUTO,
+ ],
+ icon="mdi:brightness-6",
+ exists_fn=lambda device: is_outlet(device) and device.supports_nightlight,
+ select_option_fn=lambda device, value: device.set_nightlight_state(value),
+ current_option_fn=lambda device: device.state.nightlight_status,
),
]
@@ -107,7 +123,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -149,7 +167,6 @@ class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Set an option."""
- if await self.hass.async_add_executor_job(
- self.entity_description.select_option_fn, self.device, option
- ):
- await self.coordinator.async_request_refresh()
+ if not await self.entity_description.select_option_fn(self.device, option):
+ raise HomeAssistantError(self.device.last_response.message)
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py
index 3bc6608989a..0614e522c51 100644
--- a/homeassistant/components/vesync/sensor.py
+++ b/homeassistant/components/vesync/sensor.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -28,15 +28,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .common import is_humidifier
-from .const import (
- DEV_TYPE_TO_HA,
- DOMAIN,
- SKU_TO_BASE_DEVICE,
- VS_COORDINATOR,
- VS_DEVICES,
- VS_DISCOVERY,
-)
+from .common import is_humidifier, is_outlet, rgetattr
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -49,53 +42,9 @@ class VeSyncSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[VeSyncBaseDevice], StateType]
- exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True
- update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None
+ exists_fn: Callable[[VeSyncBaseDevice], bool]
-def update_energy(device):
- """Update outlet details and energy usage."""
- device.update()
- device.update_energy()
-
-
-def sku_supported(device, supported):
- """Get the base device of which a device is an instance."""
- return SKU_TO_BASE_DEVICE.get(device.device_type) in supported
-
-
-def ha_dev_type(device):
- """Get the homeassistant device_type for a given device."""
- return DEV_TYPE_TO_HA.get(device.device_type)
-
-
-FILTER_LIFE_SUPPORTED = [
- "LV-PUR131S",
- "Core200S",
- "Core300S",
- "Core400S",
- "Core600S",
- "EverestAir",
- "Vital100S",
- "Vital200S",
-]
-AIR_QUALITY_SUPPORTED = [
- "LV-PUR131S",
- "Core300S",
- "Core400S",
- "Core600S",
- "Vital100S",
- "Vital200S",
-]
-PM25_SUPPORTED = [
- "Core300S",
- "Core400S",
- "Core600S",
- "EverestAir",
- "Vital100S",
- "Vital200S",
-]
-
SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
VeSyncSensorEntityDescription(
key="filter-life",
@@ -103,22 +52,24 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
- value_fn=lambda device: device.filter_life,
- exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED),
+ value_fn=lambda device: device.state.filter_life,
+ exists_fn=lambda device: rgetattr(device, "state.filter_life") is not None,
),
VeSyncSensorEntityDescription(
key="air-quality",
translation_key="air_quality",
- value_fn=lambda device: device.details["air_quality"],
- exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED),
+ value_fn=lambda device: device.state.air_quality_string,
+ exists_fn=(
+ lambda device: rgetattr(device, "state.air_quality_string") is not None
+ ),
),
VeSyncSensorEntityDescription(
key="pm25",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda device: device.details["air_quality_value"],
- exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED),
+ value_fn=lambda device: device.state.pm25,
+ exists_fn=lambda device: rgetattr(device, "state.pm25") is not None,
),
VeSyncSensorEntityDescription(
key="power",
@@ -126,9 +77,8 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda device: device.details["power"],
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: device.state.power,
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="energy",
@@ -136,9 +86,8 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.energy_today,
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: device.state.energy,
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="energy-weekly",
@@ -146,9 +95,10 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.weekly_energy_total,
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: getattr(
+ device.state.weekly_history, "totalEnergy", None
+ ),
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="energy-monthly",
@@ -156,9 +106,10 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.monthly_energy_total,
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: getattr(
+ device.state.monthly_history, "totalEnergy", None
+ ),
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="energy-yearly",
@@ -166,9 +117,10 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.yearly_energy_total,
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: getattr(
+ device.state.yearly_history, "totalEnergy", None
+ ),
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="voltage",
@@ -176,16 +128,15 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda device: device.details["voltage"],
- update_fn=update_energy,
- exists_fn=lambda device: ha_dev_type(device) == "outlet",
+ value_fn=lambda device: device.state.voltage,
+ exists_fn=is_outlet,
),
VeSyncSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
- value_fn=lambda device: device.details["humidity"],
+ value_fn=lambda device: device.state.humidity,
exists_fn=is_humidifier,
),
)
@@ -209,7 +160,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -251,7 +204,3 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
-
- def update(self) -> None:
- """Run the update function defined for the sensor."""
- return self.entity_description.update_fn(self.device)
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
index 06fbd3606bd..8d2feb27405 100644
--- a/homeassistant/components/vesync/switch.py
+++ b/homeassistant/components/vesync/switch.py
@@ -1,11 +1,11 @@
"""Support for VeSync switches."""
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any, Final
-from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import is_outlet, is_wall_switch, rgetattr
-from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
+from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -32,14 +32,14 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription):
is_on: Callable[[VeSyncBaseDevice], bool]
exists_fn: Callable[[VeSyncBaseDevice], bool]
- on_fn: Callable[[VeSyncBaseDevice], bool]
- off_fn: Callable[[VeSyncBaseDevice], bool]
+ on_fn: Callable[[VeSyncBaseDevice], Awaitable[bool]]
+ off_fn: Callable[[VeSyncBaseDevice], Awaitable[bool]]
SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = (
VeSyncSwitchEntityDescription(
key="device_status",
- is_on=lambda device: device.device_status == "on",
+ is_on=lambda device: device.state.device_status == "on",
# Other types of wall switches support dimming. Those use light.py platform.
exists_fn=lambda device: is_wall_switch(device) or is_outlet(device),
name=None,
@@ -48,11 +48,13 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = (
),
VeSyncSwitchEntityDescription(
key="display",
- is_on=lambda device: device.display_state,
- exists_fn=lambda device: rgetattr(device, "display_state") is not None,
+ is_on=lambda device: device.state.display_set_status == "on",
+ exists_fn=(
+ lambda device: rgetattr(device, "state.display_set_status") is not None
+ ),
translation_key="display",
- on_fn=lambda device: device.turn_on_display(),
- off_fn=lambda device: device.turn_off_display(),
+ on_fn=lambda device: device.toggle_display(True),
+ off_fn=lambda device: device.toggle_display(False),
),
)
@@ -75,7 +77,9 @@ async def async_setup_entry(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
)
- _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+ _setup_entities(
+ hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator
+ )
@callback
@@ -118,16 +122,16 @@ class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity):
"""Return the entity value to represent the entity state."""
return self.entity_description.is_on(self.device)
- def turn_off(self, **kwargs: Any) -> None:
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- if not self.entity_description.off_fn(self.device):
- raise HomeAssistantError("An error occurred while turning off.")
+ if not await self.entity_description.off_fn(self.device):
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
- def turn_on(self, **kwargs: Any) -> None:
+ async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- if not self.entity_description.on_fn(self.device):
- raise HomeAssistantError("An error occurred while turning on.")
+ if not await self.entity_description.on_fn(self.device):
+ raise HomeAssistantError(self.device.last_response.message)
self.schedule_update_ha_state()
diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py
index bcf41223d3f..c874b9f173c 100644
--- a/homeassistant/components/vicare/const.py
+++ b/homeassistant/components/vicare/const.py
@@ -19,6 +19,7 @@ PLATFORMS = [
UNSUPPORTED_DEVICES = [
"Heatbox1",
"Heatbox2_SRC",
+ "E3_TCU10_x07",
"E3_TCU41_x04",
"E3_FloorHeatingCircuitChannel",
"E3_FloorHeatingCircuitDistributorBox",
@@ -33,12 +34,13 @@ CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
+VICARE_BAR = "bar"
+VICARE_CUBIC_METER = "cubicMeter"
+VICARE_KW = "kilowatt"
+VICARE_KWH = "kilowattHour"
VICARE_PERCENT = "percent"
VICARE_W = "watt"
-VICARE_KW = "kilowatt"
VICARE_WH = "wattHour"
-VICARE_KWH = "kilowattHour"
-VICARE_CUBIC_METER = "cubicMeter"
class HeatingType(enum.Enum):
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 8e632e46efe..11ba2a31b2a 100644
--- a/homeassistant/components/vicare/manifest.json
+++ b/homeassistant/components/vicare/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/vicare",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
- "requirements": ["PyViCare==2.50.0"]
+ "requirements": ["PyViCare==2.52.0"]
}
diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py
index 04c4088bd3e..68e310c089e 100644
--- a/homeassistant/components/vicare/number.py
+++ b/homeassistant/components/vicare/number.py
@@ -24,6 +24,7 @@ from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
+ NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
@@ -59,6 +60,7 @@ DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDomesticHotWaterConfiguredTemperature(),
value_setter=lambda api, value: api.setDomesticHotWaterTemperature(value),
min_value_getter=lambda api: api.getDomesticHotWaterMinTemperature(),
@@ -71,6 +73,7 @@ DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDomesticHotWaterConfiguredTemperature2(),
value_setter=lambda api, value: api.setDomesticHotWaterTemperature2(value),
# no getters for min, max, stepping exposed yet, using static values
@@ -84,6 +87,7 @@ DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.KELVIN,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOn(),
value_setter=lambda api, value: api.setDomesticHotWaterHysteresisSwitchOn(
value
@@ -98,6 +102,7 @@ DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.KELVIN,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOff(),
value_setter=lambda api, value: api.setDomesticHotWaterHysteresisSwitchOff(
value
@@ -116,6 +121,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getHeatingCurveShift(),
value_setter=lambda api, shift: (
api.setHeatingCurve(shift, api.getHeatingCurveSlope())
@@ -131,6 +137,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
key="heating curve slope",
translation_key="heating_curve_slope",
entity_category=EntityCategory.CONFIG,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getHeatingCurveSlope(),
value_setter=lambda api, slope: (
api.setHeatingCurve(api.getHeatingCurveShift(), slope)
@@ -148,6 +155,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.NORMAL
),
@@ -168,6 +176,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.REDUCED
),
@@ -188,6 +197,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.COMFORT
),
@@ -208,6 +218,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.NORMAL_HEATING
),
@@ -230,6 +241,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.REDUCED_HEATING
),
@@ -252,6 +264,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.COMFORT_HEATING
),
@@ -274,6 +287,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.NORMAL_COOLING
),
@@ -296,6 +310,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.REDUCED_COOLING
),
@@ -318,6 +333,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ mode=NumberMode.BOX,
value_getter=lambda api: api.getDesiredTemperatureForProgram(
HeatingProgram.COMFORT_COOLING
),
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index cddc5ca021a..864439c746c 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -41,6 +41,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
+ VICARE_BAR,
VICARE_CUBIC_METER,
VICARE_KW,
VICARE_KWH,
@@ -62,20 +63,22 @@ from .utils import (
_LOGGER = logging.getLogger(__name__)
VICARE_UNIT_TO_DEVICE_CLASS = {
- VICARE_WH: SensorDeviceClass.ENERGY,
- VICARE_KWH: SensorDeviceClass.ENERGY,
- VICARE_W: SensorDeviceClass.POWER,
- VICARE_KW: SensorDeviceClass.POWER,
+ VICARE_BAR: SensorDeviceClass.PRESSURE,
VICARE_CUBIC_METER: SensorDeviceClass.GAS,
+ VICARE_KW: SensorDeviceClass.POWER,
+ VICARE_KWH: SensorDeviceClass.ENERGY,
+ VICARE_WH: SensorDeviceClass.ENERGY,
+ VICARE_W: SensorDeviceClass.POWER,
}
VICARE_UNIT_TO_HA_UNIT = {
+ VICARE_BAR: UnitOfPressure.BAR,
+ VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
+ VICARE_KW: UnitOfPower.KILO_WATT,
+ VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
VICARE_PERCENT: PERCENTAGE,
VICARE_W: UnitOfPower.WATT,
- VICARE_KW: UnitOfPower.KILO_WATT,
VICARE_WH: UnitOfEnergy.WATT_HOUR,
- VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
- VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
}
@@ -193,6 +196,15 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
+ ViCareSensorEntityDescription(
+ key="dhw_storage_middle_temperature",
+ translation_key="dhw_storage_middle_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value_getter=lambda api: api.getDomesticHotWaterStorageTemperatureMiddle(),
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
ViCareSensorEntityDescription(
key="dhw_storage_bottom_temperature",
translation_key="dhw_storage_bottom_temperature",
@@ -708,6 +720,13 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
options=["charge", "discharge", "standby"],
value_getter=lambda api: api.getElectricalEnergySystemOperationState(),
),
+ ViCareSensorEntityDescription(
+ key="ess_charge_total",
+ translation_key="ess_charge_total",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_getter=lambda api: api.getElectricalEnergySystemTransferChargeCumulatedLifeCycle(),
+ unit_getter=lambda api: api.getElectricalEnergySystemTransferChargeCumulatedUnit(),
+ ),
ViCareSensorEntityDescription(
key="ess_discharge_today",
translation_key="ess_discharge_today",
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index dd8d93e609a..260b51f56f3 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -182,6 +182,9 @@
"dhw_storage_top_temperature": {
"name": "DHW storage top temperature"
},
+ "dhw_storage_middle_temperature": {
+ "name": "DHW storage middle temperature"
+ },
"dhw_storage_bottom_temperature": {
"name": "DHW storage bottom temperature"
},
@@ -367,6 +370,9 @@
"standby": "[%key:common::state::standby%]"
}
},
+ "ess_charge_total": {
+ "name": "Battery charge total"
+ },
"ess_discharge_today": {
"name": "Battery discharge today"
},
diff --git a/homeassistant/components/victron_remote_monitoring/__init__.py b/homeassistant/components/victron_remote_monitoring/__init__.py
new file mode 100644
index 00000000000..15cddedc4ed
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/__init__.py
@@ -0,0 +1,34 @@
+"""The Victron VRM Solar Forecast integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import (
+ VictronRemoteMonitoringConfigEntry,
+ VictronRemoteMonitoringDataUpdateCoordinator,
+)
+
+_PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: VictronRemoteMonitoringConfigEntry
+) -> bool:
+ """Set up VRM from a config entry."""
+ coordinator = VictronRemoteMonitoringDataUpdateCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: VictronRemoteMonitoringConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/victron_remote_monitoring/config_flow.py b/homeassistant/components/victron_remote_monitoring/config_flow.py
new file mode 100644
index 00000000000..83649e8e5c5
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/config_flow.py
@@ -0,0 +1,255 @@
+"""Config flow for the Victron VRM Solar Forecast integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from victron_vrm import VictronVRMClient
+from victron_vrm.exceptions import AuthenticationError, VictronVRMError
+from victron_vrm.models import Site
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str})
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class SiteNotFound(HomeAssistantError):
+ """Error to indicate the site was not found."""
+
+
+class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Victron Remote Monitoring.
+
+ Supports reauthentication when the stored token becomes invalid.
+ """
+
+ VERSION = 1
+
+ def __init__(self) -> None:
+ """Initialize flow state."""
+ self._api_token: str | None = None
+ self._sites: list[Site] = []
+
+ def _build_site_options(self) -> list[SelectOptionDict]:
+ """Build selector options for the available sites."""
+ return [
+ SelectOptionDict(
+ value=str(site.id), label=f"{(site.name or 'Site')} (ID:{site.id})"
+ )
+ for site in self._sites
+ ]
+
+ async def _async_validate_token_and_fetch_sites(self, api_token: str) -> list[Site]:
+ """Validate the API token and return available sites.
+
+ Raises InvalidAuth on bad/unauthorized token; CannotConnect on other errors.
+ """
+ client = VictronVRMClient(
+ token=api_token,
+ client_session=get_async_client(self.hass),
+ )
+ try:
+ sites = await client.users.list_sites()
+ except AuthenticationError as err:
+ raise InvalidAuth("Invalid authentication or permission") from err
+ except VictronVRMError as err:
+ if getattr(err, "status_code", None) in (401, 403):
+ raise InvalidAuth("Invalid authentication or permission") from err
+ raise CannotConnect(f"Cannot connect to VRM API: {err}") from err
+ else:
+ return sites
+
+ async def _async_validate_selected_site(self, api_token: str, site_id: int) -> Site:
+ """Validate access to the selected site and return its data."""
+ client = VictronVRMClient(
+ token=api_token,
+ client_session=get_async_client(self.hass),
+ )
+ try:
+ site_data = await client.users.get_site(site_id)
+ except AuthenticationError as err:
+ raise InvalidAuth("Invalid authentication or permission") from err
+ except VictronVRMError as err:
+ if getattr(err, "status_code", None) in (401, 403):
+ raise InvalidAuth("Invalid authentication or permission") from err
+ raise CannotConnect(f"Cannot connect to VRM API: {err}") from err
+ if site_data is None:
+ raise SiteNotFound(f"Site with ID {site_id} not found")
+ return site_data
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """First step: ask for API token and validate it."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ api_token: str = user_input[CONF_API_TOKEN]
+ try:
+ sites = await self._async_validate_token_and_fetch_sites(api_token)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if not sites:
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors={"base": "no_sites"},
+ )
+ self._api_token = api_token
+ # Sort sites by name then id for stable order
+ self._sites = sorted(sites, key=lambda s: (s.name or "", s.id))
+ if len(self._sites) == 1:
+ # Only one site available, skip site selection step
+ site = self._sites[0]
+ await self.async_set_unique_id(
+ str(site.id), raise_on_progress=False
+ )
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=f"VRM for {site.name}",
+ data={CONF_API_TOKEN: self._api_token, CONF_SITE_ID: site.id},
+ )
+ return await self.async_step_select_site()
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_select_site(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Second step: present sites and validate selection."""
+ assert self._api_token is not None
+
+ if user_input is None:
+ site_options = self._build_site_options()
+ return self.async_show_form(
+ step_id="select_site",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_SITE_ID): SelectSelector(
+ SelectSelectorConfig(
+ options=site_options, mode=SelectSelectorMode.DROPDOWN
+ )
+ )
+ }
+ ),
+ )
+
+ # User submitted a site selection
+ site_id = int(user_input[CONF_SITE_ID])
+ # Prevent duplicate entries for the same site
+ self._async_abort_entries_match({CONF_SITE_ID: site_id})
+
+ errors: dict[str, str] = {}
+ try:
+ site = await self._async_validate_selected_site(self._api_token, site_id)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except SiteNotFound:
+ errors["base"] = "site_not_found"
+ except Exception: # pragma: no cover - unexpected
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ # Ensure unique ID per site to avoid duplicates across reloads
+ await self.async_set_unique_id(str(site_id), raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=f"VRM for {site.name}",
+ data={CONF_API_TOKEN: self._api_token, CONF_SITE_ID: site_id},
+ )
+
+ # If we reach here, show the selection form again with errors
+ site_options = self._build_site_options()
+ return self.async_show_form(
+ step_id="select_site",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_SITE_ID): SelectSelector(
+ SelectSelectorConfig(
+ options=site_options, mode=SelectSelectorMode.DROPDOWN
+ )
+ )
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Start reauthentication by asking for a (new) API token.
+
+ We only need the token again; the site is fixed per entry and set as unique id.
+ """
+ self._api_token = None
+ self._sites = []
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauthentication confirmation with new token."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+
+ if user_input is not None:
+ new_token = user_input[CONF_API_TOKEN]
+ site_id: int = reauth_entry.data[CONF_SITE_ID]
+ try:
+ # Validate the token by fetching the site for the existing entry
+ await self._async_validate_selected_site(new_token, site_id)
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except SiteNotFound:
+ # Site removed or no longer visible to the account; treat as cannot connect
+ errors["base"] = "site_not_found"
+ except Exception: # pragma: no cover - unexpected
+ _LOGGER.exception("Unexpected exception during reauth")
+ errors["base"] = "unknown"
+ else:
+ # Update stored token and reload entry
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_API_TOKEN: new_token},
+ reason="reauth_successful",
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
+ errors=errors,
+ )
diff --git a/homeassistant/components/victron_remote_monitoring/const.py b/homeassistant/components/victron_remote_monitoring/const.py
new file mode 100644
index 00000000000..3de1dbcabb2
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/const.py
@@ -0,0 +1,9 @@
+"""Constants for the Victron VRM Solar Forecast integration."""
+
+import logging
+
+DOMAIN = "victron_remote_monitoring"
+LOGGER = logging.getLogger(__package__)
+
+CONF_SITE_ID = "site_id"
+CONF_API_TOKEN = "api_token"
diff --git a/homeassistant/components/victron_remote_monitoring/coordinator.py b/homeassistant/components/victron_remote_monitoring/coordinator.py
new file mode 100644
index 00000000000..68cae39813d
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/coordinator.py
@@ -0,0 +1,98 @@
+"""VRM Coordinator and Client."""
+
+from dataclasses import dataclass
+import datetime
+
+from victron_vrm import VictronVRMClient
+from victron_vrm.exceptions import AuthenticationError, VictronVRMError
+from victron_vrm.models.aggregations import ForecastAggregations
+from victron_vrm.utils import dt_now
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, LOGGER
+
+type VictronRemoteMonitoringConfigEntry = ConfigEntry[
+ VictronRemoteMonitoringDataUpdateCoordinator
+]
+
+
+@dataclass
+class VRMForecastStore:
+ """Class to hold the forecast data."""
+
+ site_id: int
+ solar: ForecastAggregations
+ consumption: ForecastAggregations
+
+
+async def get_forecast(client: VictronVRMClient, site_id: int) -> VRMForecastStore:
+ """Get the forecast data."""
+ start = int(
+ (
+ dt_now().replace(hour=0, minute=0, second=0, microsecond=0)
+ - datetime.timedelta(days=1)
+ ).timestamp()
+ )
+ # Get timestamp of the end of 6th day from now
+ end = int(
+ (
+ dt_now().replace(hour=0, minute=0, second=0, microsecond=0)
+ + datetime.timedelta(days=6)
+ ).timestamp()
+ )
+ stats = await client.installations.stats(
+ site_id,
+ start=start,
+ end=end,
+ interval="hours",
+ type="forecast",
+ return_aggregations=True,
+ )
+ return VRMForecastStore(
+ solar=stats["solar_yield"],
+ consumption=stats["consumption"],
+ site_id=site_id,
+ )
+
+
+class VictronRemoteMonitoringDataUpdateCoordinator(
+ DataUpdateCoordinator[VRMForecastStore]
+):
+ """Class to manage fetching VRM Forecast data."""
+
+ config_entry: VictronRemoteMonitoringConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: VictronRemoteMonitoringConfigEntry,
+ ) -> None:
+ """Initialize."""
+ self.client = VictronVRMClient(
+ token=config_entry.data[CONF_API_TOKEN],
+ client_session=get_async_client(hass),
+ )
+ self.site_id = config_entry.data[CONF_SITE_ID]
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=datetime.timedelta(minutes=60),
+ )
+
+ async def _async_update_data(self) -> VRMForecastStore:
+ """Fetch data from VRM API."""
+ try:
+ return await get_forecast(self.client, self.site_id)
+ except AuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ f"Invalid authentication for VRM API: {err}"
+ ) from err
+ except VictronVRMError as err:
+ raise UpdateFailed(f"Cannot connect to VRM API: {err}") from err
diff --git a/homeassistant/components/victron_remote_monitoring/manifest.json b/homeassistant/components/victron_remote_monitoring/manifest.json
new file mode 100644
index 00000000000..1ce45ad2475
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "victron_remote_monitoring",
+ "name": "Victron Remote Monitoring",
+ "codeowners": ["@AndyTempel"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/victron_remote_monitoring",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "quality_scale": "bronze",
+ "requirements": ["victron-vrm==0.1.7"]
+}
diff --git a/homeassistant/components/victron_remote_monitoring/quality_scale.yaml b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml
new file mode 100644
index 00000000000..7e3f009b868
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/quality_scale.yaml
@@ -0,0 +1,66 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: "This integration does not use actions."
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: "This integration does not use actions."
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: "This integration does not use actions."
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: done
+ entity-unavailable: todo
+ integration-owner: todo
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/victron_remote_monitoring/sensor.py b/homeassistant/components/victron_remote_monitoring/sensor.py
new file mode 100644
index 00000000000..8876f784fa8
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/sensor.py
@@ -0,0 +1,250 @@
+"""Support for the VRM Solar Forecast sensor service."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import EntityCategory, UnitOfEnergy
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import (
+ VictronRemoteMonitoringConfigEntry,
+ VictronRemoteMonitoringDataUpdateCoordinator,
+ VRMForecastStore,
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class VRMForecastsSensorEntityDescription(SensorEntityDescription):
+ """Describes a VRM Forecast Sensor."""
+
+ value_fn: Callable[[VRMForecastStore], int | float | datetime | None]
+
+
+SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = (
+ # Solar forecast sensors
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_estimate_yesterday",
+ translation_key="energy_production_estimate_yesterday",
+ value_fn=lambda estimate: estimate.solar.yesterday_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_estimate_today",
+ translation_key="energy_production_estimate_today",
+ value_fn=lambda estimate: estimate.solar.today_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_estimate_today_remaining",
+ translation_key="energy_production_estimate_today_remaining",
+ value_fn=lambda estimate: estimate.solar.today_left_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_estimate_tomorrow",
+ translation_key="energy_production_estimate_tomorrow",
+ value_fn=lambda estimate: estimate.solar.tomorrow_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="power_highest_peak_time_yesterday",
+ translation_key="power_highest_peak_time_yesterday",
+ value_fn=lambda estimate: estimate.solar.yesterday_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="power_highest_peak_time_today",
+ translation_key="power_highest_peak_time_today",
+ value_fn=lambda estimate: estimate.solar.today_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="power_highest_peak_time_tomorrow",
+ translation_key="power_highest_peak_time_tomorrow",
+ value_fn=lambda estimate: estimate.solar.tomorrow_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_current_hour",
+ translation_key="energy_production_current_hour",
+ value_fn=lambda estimate: estimate.solar.current_hour_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_production_next_hour",
+ translation_key="energy_production_next_hour",
+ value_fn=lambda estimate: estimate.solar.next_hour_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ # Consumption forecast sensors
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_estimate_yesterday",
+ translation_key="energy_consumption_estimate_yesterday",
+ value_fn=lambda estimate: estimate.consumption.yesterday_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_estimate_today",
+ translation_key="energy_consumption_estimate_today",
+ value_fn=lambda estimate: estimate.consumption.today_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_estimate_today_remaining",
+ translation_key="energy_consumption_estimate_today_remaining",
+ value_fn=lambda estimate: estimate.consumption.today_left_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_estimate_tomorrow",
+ translation_key="energy_consumption_estimate_tomorrow",
+ value_fn=lambda estimate: estimate.consumption.tomorrow_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="consumption_highest_peak_time_yesterday",
+ translation_key="consumption_highest_peak_time_yesterday",
+ value_fn=lambda estimate: estimate.consumption.yesterday_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="consumption_highest_peak_time_today",
+ translation_key="consumption_highest_peak_time_today",
+ value_fn=lambda estimate: estimate.consumption.today_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="consumption_highest_peak_time_tomorrow",
+ translation_key="consumption_highest_peak_time_tomorrow",
+ value_fn=lambda estimate: estimate.consumption.tomorrow_peak_time,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_current_hour",
+ translation_key="energy_consumption_current_hour",
+ value_fn=lambda estimate: estimate.consumption.current_hour_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+ VRMForecastsSensorEntityDescription(
+ key="energy_consumption_next_hour",
+ translation_key="energy_consumption_next_hour",
+ value_fn=lambda estimate: estimate.consumption.next_hour_total,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ suggested_display_precision=1,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: VictronRemoteMonitoringConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Defer sensor setup to the shared sensor module."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ VRMForecastsSensorEntity(
+ entry_id=entry.entry_id,
+ coordinator=coordinator,
+ description=entity_description,
+ )
+ for entity_description in SENSORS
+ )
+
+
+class VRMForecastsSensorEntity(
+ CoordinatorEntity[VictronRemoteMonitoringDataUpdateCoordinator], SensorEntity
+):
+ """Defines a VRM Solar Forecast sensor."""
+
+ entity_description: VRMForecastsSensorEntityDescription
+ _attr_has_entity_name = True
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ *,
+ entry_id: str,
+ coordinator: VictronRemoteMonitoringDataUpdateCoordinator,
+ description: VRMForecastsSensorEntityDescription,
+ ) -> None:
+ """Initialize VRM Solar Forecast sensor."""
+ super().__init__(coordinator=coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.data.site_id}|{description.key}"
+
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, str(coordinator.data.site_id))},
+ manufacturer="Victron Energy",
+ model=f"VRM - {coordinator.data.site_id}",
+ name="Victron Remote Monitoring",
+ configuration_url="https://vrm.victronenergy.com",
+ )
+
+ @property
+ def native_value(self) -> datetime | StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/victron_remote_monitoring/strings.json b/homeassistant/components/victron_remote_monitoring/strings.json
new file mode 100644
index 00000000000..8047705599d
--- /dev/null
+++ b/homeassistant/components/victron_remote_monitoring/strings.json
@@ -0,0 +1,102 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Enter your VRM API access token. We will then fetch your available sites.",
+ "data": {
+ "api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "The API access token for your VRM account"
+ }
+ },
+ "select_site": {
+ "description": "Select the VRM site",
+ "data": {
+ "site_id": "VRM site"
+ },
+ "data_description": {
+ "site_id": "Select one of your VRM sites"
+ }
+ },
+ "reauth_confirm": {
+ "description": "Your existing token is no longer valid. Please enter a new VRM API access token to reauthenticate.",
+ "data": {
+ "api_token": "[%key:common::config_flow::data::api_token%]"
+ },
+ "data_description": {
+ "api_token": "The new API access token for your VRM account"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "no_sites": "No sites found for this account",
+ "site_not_found": "Site ID not found. Please check the ID and try again.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "energy_production_estimate_yesterday": {
+ "name": "Estimated energy production - Yesterday"
+ },
+ "energy_production_estimate_today": {
+ "name": "Estimated energy production - Today"
+ },
+ "energy_production_estimate_today_remaining": {
+ "name": "Estimated energy production - Today remaining"
+ },
+ "energy_production_estimate_tomorrow": {
+ "name": "Estimated energy production - Tomorrow"
+ },
+ "power_highest_peak_time_yesterday": {
+ "name": "Highest peak time - Yesterday"
+ },
+ "power_highest_peak_time_today": {
+ "name": "Highest peak time - Today"
+ },
+ "power_highest_peak_time_tomorrow": {
+ "name": "Highest peak time - Tomorrow"
+ },
+ "energy_production_current_hour": {
+ "name": "Estimated energy production - Current hour"
+ },
+ "energy_production_next_hour": {
+ "name": "Estimated energy production - Next hour"
+ },
+ "energy_consumption_estimate_yesterday": {
+ "name": "Estimated energy consumption - Yesterday"
+ },
+ "energy_consumption_estimate_today": {
+ "name": "Estimated energy consumption - Today"
+ },
+ "energy_consumption_estimate_today_remaining": {
+ "name": "Estimated energy consumption - Today remaining"
+ },
+ "energy_consumption_estimate_tomorrow": {
+ "name": "Estimated energy consumption - Tomorrow"
+ },
+ "consumption_highest_peak_time_yesterday": {
+ "name": "Highest consumption peak time - Yesterday"
+ },
+ "consumption_highest_peak_time_today": {
+ "name": "Highest consumption peak time - Today"
+ },
+ "consumption_highest_peak_time_tomorrow": {
+ "name": "Highest consumption peak time - Tomorrow"
+ },
+ "energy_consumption_current_hour": {
+ "name": "Estimated energy consumption - Current hour"
+ },
+ "energy_consumption_next_hour": {
+ "name": "Estimated energy consumption - Next hour"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json
index f0b622afcad..74a8bf9b750 100644
--- a/homeassistant/components/vivotek/manifest.json
+++ b/homeassistant/components/vivotek/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["libpyvivotek"],
"quality_scale": "legacy",
- "requirements": ["libpyvivotek==0.4.0"]
+ "requirements": ["libpyvivotek==0.6.1"]
}
diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py
index 35c32ab2af3..5a3330b16c6 100644
--- a/homeassistant/components/vodafone_station/coordinator.py
+++ b/homeassistant/components/vodafone_station/coordinator.py
@@ -8,11 +8,14 @@ from typing import Any, cast
from aiohttp import ClientSession
from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions
-from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME
+from homeassistant.components.device_tracker import (
+ DEFAULT_CONSIDER_HOME,
+ DOMAIN as DEVICE_TRACKER_DOMAIN,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -71,16 +74,14 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
update_interval=timedelta(seconds=SCAN_INTERVAL),
config_entry=config_entry,
)
- device_reg = dr.async_get(self.hass)
- device_list = dr.async_entries_for_config_entry(
- device_reg, self.config_entry.entry_id
- )
+ entity_reg = er.async_get(hass)
self.previous_devices = {
- connection[1].upper()
- for device in device_list
- for connection in device.connections
- if connection[0] == dr.CONNECTION_NETWORK_MAC
+ entry.unique_id
+ for entry in er.async_entries_for_config_entry(
+ entity_reg, config_entry.entry_id
+ )
+ if entry.domain == DEVICE_TRACKER_DOMAIN
}
def _calculate_update_time_and_consider_home(
diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json
index 4c33cf1a4a5..a9ee2f49b4c 100644
--- a/homeassistant/components/vodafone_station/manifest.json
+++ b/homeassistant/components/vodafone_station/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
"quality_scale": "platinum",
- "requirements": ["aiovodafone==0.10.0"]
+ "requirements": ["aiovodafone==1.2.1"]
}
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index fe855159d55..ee98506e728 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "voip",
"name": "Voice over IP",
- "codeowners": ["@balloob", "@synesthesiam", "@jaminh"],
+ "codeowners": ["@synesthesiam", "@jaminh"],
"config_flow": true,
"dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"],
"documentation": "https://www.home-assistant.io/integrations/voip",
diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py
index c6632185f0a..403dce7bfe6 100644
--- a/homeassistant/components/volvo/__init__.py
+++ b/homeassistant/components/volvo/__init__.py
@@ -4,9 +4,8 @@ from __future__ import annotations
import asyncio
-from aiohttp import ClientResponseError
from volvocarsapi.api import VolvoCarsApi
-from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle
+from volvocarsapi.models import VolvoApiException, VolvoAuthException, VolvoCarsVehicle
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
@@ -25,7 +24,10 @@ from .api import VolvoAuth
from .const import CONF_VIN, DOMAIN, PLATFORMS
from .coordinator import (
VolvoConfigEntry,
+ VolvoContext,
+ VolvoFastIntervalCoordinator,
VolvoMediumIntervalCoordinator,
+ VolvoRuntimeData,
VolvoSlowIntervalCoordinator,
VolvoVerySlowIntervalCoordinator,
)
@@ -36,17 +38,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo
api = await _async_auth_and_create_api(hass, entry)
vehicle = await _async_load_vehicle(api)
+ context = VolvoContext(api, vehicle)
# Order is important! Faster intervals must come first.
+ # Different interval coordinators are in place to keep the number
+ # of requests under 5000 per day. This lets users use the same
+ # API key for two vehicles (as the limit is 10000 per day).
coordinators = (
- VolvoMediumIntervalCoordinator(hass, entry, api, vehicle),
- VolvoSlowIntervalCoordinator(hass, entry, api, vehicle),
- VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle),
+ VolvoFastIntervalCoordinator(hass, entry, context),
+ VolvoMediumIntervalCoordinator(hass, entry, context),
+ VolvoSlowIntervalCoordinator(hass, entry, context),
+ VolvoVerySlowIntervalCoordinator(hass, entry, context),
)
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
- entry.runtime_data = coordinators
+ entry.runtime_data = VolvoRuntimeData(coordinators)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -64,22 +71,22 @@ async def _async_auth_and_create_api(
oauth_session = OAuth2Session(hass, entry, implementation)
web_session = async_get_clientsession(hass)
auth = VolvoAuth(web_session, oauth_session)
-
- try:
- await auth.async_get_access_token()
- except ClientResponseError as err:
- if err.status in (400, 401):
- raise ConfigEntryAuthFailed from err
-
- raise ConfigEntryNotReady from err
-
- return VolvoCarsApi(
+ api = VolvoCarsApi(
web_session,
auth,
entry.data[CONF_API_KEY],
entry.data[CONF_VIN],
)
+ try:
+ await api.async_get_access_token()
+ except VolvoAuthException as err:
+ raise ConfigEntryAuthFailed from err
+ except VolvoApiException as err:
+ raise ConfigEntryNotReady from err
+
+ return api
+
async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle:
try:
diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py
index e2c1070f1ea..bf41bcf6c2e 100644
--- a/homeassistant/components/volvo/api.py
+++ b/homeassistant/components/volvo/api.py
@@ -1,11 +1,16 @@
"""API for Volvo bound to Home Assistant OAuth."""
+import logging
from typing import cast
from aiohttp import ClientSession
from volvocarsapi.auth import AccessTokenManager
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
+from homeassistant.helpers.redact import async_redact_data
+
+_LOGGER = logging.getLogger(__name__)
+_TO_REDACT = ["access_token", "id_token", "refresh_token"]
class VolvoAuth(AccessTokenManager):
@@ -18,7 +23,20 @@ class VolvoAuth(AccessTokenManager):
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
+ current_access_token = self._oauth_session.token["access_token"]
+ current_refresh_token = self._oauth_session.token["refresh_token"]
+
await self._oauth_session.async_ensure_token_valid()
+
+ _LOGGER.debug(
+ "Token: %s", async_redact_data(self._oauth_session.token, _TO_REDACT)
+ )
+ _LOGGER.debug(
+ "Token changed: access %s, refresh %s",
+ current_access_token != self._oauth_session.token["access_token"],
+ current_refresh_token != self._oauth_session.token["refresh_token"],
+ )
+
return cast(str, self._oauth_session.token["access_token"])
diff --git a/homeassistant/components/volvo/binary_sensor.py b/homeassistant/components/volvo/binary_sensor.py
new file mode 100644
index 00000000000..fe8783d9334
--- /dev/null
+++ b/homeassistant/components/volvo/binary_sensor.py
@@ -0,0 +1,408 @@
+"""Volvo binary sensors."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from volvocarsapi.models import VolvoCarsApiBaseModel, VolvoCarsValue
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import API_NONE_VALUE
+from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry
+from .entity import VolvoEntity, VolvoEntityDescription
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class VolvoBinarySensorDescription(
+ BinarySensorEntityDescription, VolvoEntityDescription
+):
+ """Describes a Volvo binary sensor entity."""
+
+ on_values: tuple[str, ...]
+
+
+@dataclass(frozen=True, kw_only=True)
+class VolvoCarsDoorDescription(VolvoBinarySensorDescription):
+ """Describes a Volvo door entity."""
+
+ device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.DOOR
+ on_values: tuple[str, ...] = field(default=("OPEN", "AJAR"), init=False)
+
+
+@dataclass(frozen=True, kw_only=True)
+class VolvoCarsTireDescription(VolvoBinarySensorDescription):
+ """Describes a Volvo tire entity."""
+
+ device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM
+ on_values: tuple[str, ...] = field(
+ default=("VERY_LOW_PRESSURE", "LOW_PRESSURE", "HIGH_PRESSURE"), init=False
+ )
+
+
+@dataclass(frozen=True, kw_only=True)
+class VolvoCarsWindowDescription(VolvoBinarySensorDescription):
+ """Describes a Volvo window entity."""
+
+ device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW
+ on_values: tuple[str, ...] = field(default=("OPEN", "AJAR"), init=False)
+
+
+_DESCRIPTIONS: tuple[VolvoBinarySensorDescription, ...] = (
+ # brakes endpoint
+ VolvoBinarySensorDescription(
+ key="brake_fluid_level_warning",
+ api_field="brakeFluidLevelWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("TOO_LOW",),
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="brake_light_center_warning",
+ api_field="brakeLightCenterWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="brake_light_left_warning",
+ api_field="brakeLightLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="brake_light_right_warning",
+ api_field="brakeLightRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # engine endpoint
+ VolvoBinarySensorDescription(
+ key="coolant_level_warning",
+ api_field="engineCoolantLevelWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("TOO_LOW",),
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="daytime_running_light_left_warning",
+ api_field="daytimeRunningLightLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="daytime_running_light_right_warning",
+ api_field="daytimeRunningLightRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="door_front_left",
+ api_field="frontLeftDoor",
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="door_front_right",
+ api_field="frontRightDoor",
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="door_rear_left",
+ api_field="rearLeftDoor",
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="door_rear_right",
+ api_field="rearRightDoor",
+ ),
+ # engine-status endpoint
+ VolvoBinarySensorDescription(
+ key="engine_status",
+ api_field="engineStatus",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ on_values=("RUNNING",),
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="fog_light_front_warning",
+ api_field="fogLightFrontWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="fog_light_rear_warning",
+ api_field="fogLightRearWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="hazard_lights_warning",
+ api_field="hazardLightsWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="high_beam_left_warning",
+ api_field="highBeamLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="high_beam_right_warning",
+ api_field="highBeamRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="hood",
+ api_field="hood",
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="low_beam_left_warning",
+ api_field="lowBeamLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="low_beam_right_warning",
+ api_field="lowBeamRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # engine endpoint
+ VolvoBinarySensorDescription(
+ key="oil_level_warning",
+ api_field="oilLevelWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("SERVICE_REQUIRED", "TOO_LOW", "TOO_HIGH"),
+ ),
+ # position lights
+ VolvoBinarySensorDescription(
+ key="position_light_front_left_warning",
+ api_field="positionLightFrontLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # position lights
+ VolvoBinarySensorDescription(
+ key="position_light_front_right_warning",
+ api_field="positionLightFrontRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # position lights
+ VolvoBinarySensorDescription(
+ key="position_light_rear_left_warning",
+ api_field="positionLightRearLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # position lights
+ VolvoBinarySensorDescription(
+ key="position_light_rear_right_warning",
+ api_field="positionLightRearRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # registration plate light
+ VolvoBinarySensorDescription(
+ key="registration_plate_light_warning",
+ api_field="registrationPlateLightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # reverse lights
+ VolvoBinarySensorDescription(
+ key="reverse_lights_warning",
+ api_field="reverseLightsWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="side_mark_lights_warning",
+ api_field="sideMarkLightsWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # windows endpoint
+ VolvoCarsWindowDescription(
+ key="sunroof",
+ api_field="sunroof",
+ ),
+ # tyres endpoint
+ VolvoCarsTireDescription(
+ key="tire_front_left",
+ api_field="frontLeft",
+ ),
+ # tyres endpoint
+ VolvoCarsTireDescription(
+ key="tire_front_right",
+ api_field="frontRight",
+ ),
+ # tyres endpoint
+ VolvoCarsTireDescription(
+ key="tire_rear_left",
+ api_field="rearLeft",
+ ),
+ # tyres endpoint
+ VolvoCarsTireDescription(
+ key="tire_rear_right",
+ api_field="rearRight",
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="tailgate",
+ api_field="tailgate",
+ ),
+ # doors endpoint
+ VolvoCarsDoorDescription(
+ key="tank_lid",
+ api_field="tankLid",
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="turn_indication_front_left_warning",
+ api_field="turnIndicationFrontLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="turn_indication_front_right_warning",
+ api_field="turnIndicationFrontRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="turn_indication_rear_left_warning",
+ api_field="turnIndicationRearLeftWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # warnings endpoint
+ VolvoBinarySensorDescription(
+ key="turn_indication_rear_right_warning",
+ api_field="turnIndicationRearRightWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("FAILURE",),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ # diagnostics endpoint
+ VolvoBinarySensorDescription(
+ key="washer_fluid_level_warning",
+ api_field="washerFluidLevelWarning",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ on_values=("TOO_LOW",),
+ ),
+ # windows endpoint
+ VolvoCarsWindowDescription(
+ key="window_front_left",
+ api_field="frontLeftWindow",
+ ),
+ # windows endpoint
+ VolvoCarsWindowDescription(
+ key="window_front_right",
+ api_field="frontRightWindow",
+ ),
+ # windows endpoint
+ VolvoCarsWindowDescription(
+ key="window_rear_left",
+ api_field="rearLeftWindow",
+ ),
+ # windows endpoint
+ VolvoCarsWindowDescription(
+ key="window_rear_right",
+ api_field="rearRightWindow",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: VolvoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up binary sensors."""
+ coordinators = entry.runtime_data.interval_coordinators
+ async_add_entities(
+ VolvoBinarySensor(coordinator, description)
+ for coordinator in coordinators
+ for description in _DESCRIPTIONS
+ if description.api_field in coordinator.data
+ )
+
+
+class VolvoBinarySensor(VolvoEntity, BinarySensorEntity):
+ """Volvo binary sensor."""
+
+ entity_description: VolvoBinarySensorDescription
+
+ def __init__(
+ self,
+ coordinator: VolvoBaseCoordinator,
+ description: VolvoBinarySensorDescription,
+ ) -> None:
+ """Initialize entity."""
+ self._attr_extra_state_attributes = {}
+
+ super().__init__(coordinator, description)
+
+ def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
+ """Update the state of the entity."""
+ if api_field is None:
+ self._attr_is_on = None
+ return
+
+ assert isinstance(api_field, VolvoCarsValue)
+ assert isinstance(api_field.value, str)
+
+ value = api_field.value
+
+ self._attr_is_on = (
+ value in self.entity_description.on_values
+ if value.upper() != API_NONE_VALUE
+ else None
+ )
diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py
index 675fc69945e..512dc5e0804 100644
--- a/homeassistant/components/volvo/const.py
+++ b/homeassistant/components/volvo/const.py
@@ -3,12 +3,9 @@
from homeassistant.const import Platform
DOMAIN = "volvo"
-PLATFORMS: list[Platform] = [Platform.SENSOR]
-
-ATTR_API_TIMESTAMP = "api_timestamp"
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+API_NONE_VALUE = "UNSPECIFIED"
CONF_VIN = "vin"
-
DATA_BATTERY_CAPACITY = "battery_capacity_kwh"
-
MANUFACTURER = "Volvo"
diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py
index d6c8f349a52..fa4de64e052 100644
--- a/homeassistant/components/volvo/coordinator.py
+++ b/homeassistant/components/volvo/coordinator.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from abc import abstractmethod
import asyncio
from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any, cast
@@ -29,11 +30,27 @@ from .const import DATA_BATTERY_CAPACITY, DOMAIN
VERY_SLOW_INTERVAL = 60
SLOW_INTERVAL = 15
MEDIUM_INTERVAL = 2
+FAST_INTERVAL = 1
_LOGGER = logging.getLogger(__name__)
-type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]]
+@dataclass
+class VolvoContext:
+ """Volvo context."""
+
+ api: VolvoCarsApi
+ vehicle: VolvoCarsVehicle
+
+
+@dataclass
+class VolvoRuntimeData:
+ """Volvo runtime data."""
+
+ interval_coordinators: tuple[VolvoBaseIntervalCoordinator, ...]
+
+
+type VolvoConfigEntry = ConfigEntry[VolvoRuntimeData]
type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None]
@@ -47,7 +64,7 @@ def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool:
return False
-class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
+class VolvoBaseCoordinator[T: dict = dict[str, Any]](DataUpdateCoordinator[T]):
"""Volvo base coordinator."""
config_entry: VolvoConfigEntry
@@ -56,9 +73,8 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
self,
hass: HomeAssistant,
entry: VolvoConfigEntry,
- api: VolvoCarsApi,
- vehicle: VolvoCarsVehicle,
- update_interval: timedelta,
+ context: VolvoContext,
+ update_interval: timedelta | None,
name: str,
) -> None:
"""Initialize the coordinator."""
@@ -71,8 +87,34 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
update_interval=update_interval,
)
- self.api = api
- self.vehicle = vehicle
+ self.context = context
+
+ def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None:
+ """Get the API field based on the entity description."""
+
+ return self.data.get(api_field) if api_field else None
+
+
+class VolvoBaseIntervalCoordinator(VolvoBaseCoordinator[CoordinatorData]):
+ """Volvo base interval coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: VolvoConfigEntry,
+ context: VolvoContext,
+ update_interval: timedelta,
+ name: str,
+ ) -> None:
+ """Initialize the coordinator."""
+
+ super().__init__(
+ hass,
+ entry,
+ context,
+ update_interval,
+ name,
+ )
self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = []
@@ -150,11 +192,6 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
return data
- def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None:
- """Get the API field based on the entity description."""
-
- return self.data.get(api_field) if api_field else None
-
@abstractmethod
async def _async_determine_api_calls(
self,
@@ -162,23 +199,21 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
raise NotImplementedError
-class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator):
+class VolvoVerySlowIntervalCoordinator(VolvoBaseIntervalCoordinator):
"""Volvo coordinator with very slow update rate."""
def __init__(
self,
hass: HomeAssistant,
entry: VolvoConfigEntry,
- api: VolvoCarsApi,
- vehicle: VolvoCarsVehicle,
+ context: VolvoContext,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
entry,
- api,
- vehicle,
+ context,
timedelta(minutes=VERY_SLOW_INTERVAL),
"Volvo very slow interval coordinator",
)
@@ -186,43 +221,47 @@ class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator):
async def _async_determine_api_calls(
self,
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
+ api = self.context.api
+
return [
- self.api.async_get_diagnostics,
- self.api.async_get_odometer,
- self.api.async_get_statistics,
+ api.async_get_brakes_status,
+ api.async_get_diagnostics,
+ api.async_get_engine_warnings,
+ api.async_get_odometer,
+ api.async_get_statistics,
+ api.async_get_tyre_states,
+ api.async_get_warnings,
]
async def _async_update_data(self) -> CoordinatorData:
data = await super()._async_update_data()
# Add static values
- if self.vehicle.has_battery_engine():
+ if self.context.vehicle.has_battery_engine():
data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict(
{
- "value": self.vehicle.battery_capacity_kwh,
+ "value": self.context.vehicle.battery_capacity_kwh,
}
)
return data
-class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
+class VolvoSlowIntervalCoordinator(VolvoBaseIntervalCoordinator):
"""Volvo coordinator with slow update rate."""
def __init__(
self,
hass: HomeAssistant,
entry: VolvoConfigEntry,
- api: VolvoCarsApi,
- vehicle: VolvoCarsVehicle,
+ context: VolvoContext,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
entry,
- api,
- vehicle,
+ context,
timedelta(minutes=SLOW_INTERVAL),
"Volvo slow interval coordinator",
)
@@ -230,32 +269,32 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
async def _async_determine_api_calls(
self,
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
- if self.vehicle.has_combustion_engine():
+ api = self.context.api
+
+ if self.context.vehicle.has_combustion_engine():
return [
- self.api.async_get_command_accessibility,
- self.api.async_get_fuel_status,
+ api.async_get_command_accessibility,
+ api.async_get_fuel_status,
]
- return [self.api.async_get_command_accessibility]
+ return [api.async_get_command_accessibility]
-class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
+class VolvoMediumIntervalCoordinator(VolvoBaseIntervalCoordinator):
"""Volvo coordinator with medium update rate."""
def __init__(
self,
hass: HomeAssistant,
entry: VolvoConfigEntry,
- api: VolvoCarsApi,
- vehicle: VolvoCarsVehicle,
+ context: VolvoContext,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
entry,
- api,
- vehicle,
+ context,
timedelta(minutes=MEDIUM_INTERVAL),
"Volvo medium interval coordinator",
)
@@ -265,19 +304,30 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
async def _async_determine_api_calls(
self,
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
- if self.vehicle.has_battery_engine():
- capabilities = await self.api.async_get_energy_capabilities()
+ api_calls: list[Any] = []
+ api = self.context.api
+ vehicle = self.context.vehicle
+
+ if vehicle.has_battery_engine():
+ capabilities = await api.async_get_energy_capabilities()
if capabilities.get("isSupported", False):
+
+ def _normalize_key(key: str) -> str:
+ return "chargingStatus" if key == "chargingSystemStatus" else key
+
self._supported_capabilities = [
- key
+ _normalize_key(key)
for key, value in capabilities.items()
if isinstance(value, dict) and value.get("isSupported", False)
]
- return [self._async_get_energy_state]
+ api_calls.append(self._async_get_energy_state)
- return []
+ if vehicle.has_combustion_engine():
+ api_calls.append(api.async_get_engine_status)
+
+ return api_calls
async def _async_get_energy_state(
self,
@@ -290,10 +340,40 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
return field
- energy_state = await self.api.async_get_energy_state()
+ energy_state = await self.context.api.async_get_energy_state()
return {
key: _mark_ok(value)
for key, value in energy_state.items()
if key in self._supported_capabilities
}
+
+
+class VolvoFastIntervalCoordinator(VolvoBaseIntervalCoordinator):
+ """Volvo coordinator with fast update rate."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: VolvoConfigEntry,
+ context: VolvoContext,
+ ) -> None:
+ """Initialize the coordinator."""
+
+ super().__init__(
+ hass,
+ entry,
+ context,
+ timedelta(minutes=FAST_INTERVAL),
+ "Volvo fast interval coordinator",
+ )
+
+ async def _async_determine_api_calls(
+ self,
+ ) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
+ api = self.context.api
+
+ return [
+ api.async_get_doors_status,
+ api.async_get_window_states,
+ ]
diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py
index f23bd714870..a8960a5f68f 100644
--- a/homeassistant/components/volvo/entity.py
+++ b/homeassistant/components/volvo/entity.py
@@ -54,7 +54,7 @@ class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]):
coordinator.config_entry.data[CONF_VIN], description.key
)
- vehicle = coordinator.vehicle
+ vehicle = coordinator.context.vehicle
model = (
f"{vehicle.description.model} ({vehicle.model_year})"
if vehicle.fuel_type == "NONE"
diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json
index 61f67bcfe04..13d1882d848 100644
--- a/homeassistant/components/volvo/icons.json
+++ b/homeassistant/components/volvo/icons.json
@@ -1,5 +1,265 @@
{
"entity": {
+ "binary_sensor": {
+ "brake_fluid_level_warning": {
+ "default": "mdi:car-brake-fluid-level",
+ "state": {
+ "on": "mdi:car-brake-alert"
+ }
+ },
+ "brake_light_center_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "brake_light_left_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "brake_light_right_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "coolant_level_warning": {
+ "default": "mdi:car-coolant-level"
+ },
+ "daytime_running_light_left_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "daytime_running_light_right_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "door_front_left": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "door_front_right": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "door_rear_left": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "door_rear_right": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "engine_status": {
+ "default": "mdi:engine-off",
+ "state": {
+ "on": "mdi:engine"
+ }
+ },
+ "fog_light_front_warning": {
+ "default": "mdi:car-light-fog",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "fog_light_rear_warning": {
+ "default": "mdi:car-light-fog",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "hazard_lights_warning": {
+ "default": "mdi:hazard-lights",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "high_beam_left_warning": {
+ "default": "mdi:car-light-high",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "high_beam_right_warning": {
+ "default": "mdi:car-light-high",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "hood": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "low_beam_left_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "low_beam_right_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "oil_level_warning": {
+ "default": "mdi:oil-level"
+ },
+ "position_light_front_left_warning": {
+ "default": "mdi:car-parking-lights",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "position_light_front_right_warning": {
+ "default": "mdi:car-parking-lights",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "position_light_rear_left_warning": {
+ "default": "mdi:car-parking-lights",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "position_light_rear_right_warning": {
+ "default": "mdi:car-parking-lights",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "registration_plate_light_warning": {
+ "default": "mdi:lightbulb-outline",
+ "state": {
+ "on": "mdi:lightbulb-off-outline"
+ }
+ },
+ "reverse_lights_warning": {
+ "default": "mdi:car-light-dimmed",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "side_mark_lights_warning": {
+ "default": "mdi:wall-sconce-round",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "sunroof": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "tailgate": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "tank_lid": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "turn_indication_front_left_warning": {
+ "default": "mdi:arrow-left-top",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "turn_indication_front_right_warning": {
+ "default": "mdi:arrow-right-top",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "turn_indication_rear_left_warning": {
+ "default": "mdi:arrow-left-top",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "turn_indication_rear_right_warning": {
+ "default": "mdi:arrow-right-top",
+ "state": {
+ "on": "mdi:car-light-alert"
+ }
+ },
+ "tire_front_left": {
+ "default": "mdi:tire",
+ "state": {
+ "on": "mdi:car-tire-alert"
+ }
+ },
+ "tire_front_right": {
+ "default": "mdi:tire",
+ "state": {
+ "on": "mdi:car-tire-alert"
+ }
+ },
+ "tire_rear_left": {
+ "default": "mdi:tire",
+ "state": {
+ "on": "mdi:car-tire-alert"
+ }
+ },
+ "tire_rear_right": {
+ "default": "mdi:tire",
+ "state": {
+ "on": "mdi:car-tire-alert"
+ }
+ },
+ "washer_fluid_level_warning": {
+ "default": "mdi:wiper-wash",
+ "state": {
+ "on": "mdi:wiper-wash-alert"
+ }
+ },
+ "window_front_left": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "window_front_right": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "window_rear_left": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ },
+ "window_rear_right": {
+ "default": "mdi:car-door-lock",
+ "state": {
+ "on": "mdi:car-door-lock-open"
+ }
+ }
+ },
"sensor": {
"availability": {
"default": "mdi:car-connected"
diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json
index 1530634a10a..c1979582804 100644
--- a/homeassistant/components/volvo/manifest.json
+++ b/homeassistant/components/volvo/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["volvocarsapi"],
"quality_scale": "silver",
- "requirements": ["volvocarsapi==0.4.1"]
+ "requirements": ["volvocarsapi==0.4.2"]
}
diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py
index b9a620d898d..f104fabf83b 100644
--- a/homeassistant/components/volvo/sensor.py
+++ b/homeassistant/components/volvo/sensor.py
@@ -35,8 +35,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BATTERY_CAPACITY
-from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry
+from .const import API_NONE_VALUE, DATA_BATTERY_CAPACITY
+from .coordinator import VolvoConfigEntry
from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key
PARALLEL_UPDATES = 0
@@ -248,7 +248,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = (
# statistics endpoint
# We're not using `electricRange` from the energy state endpoint because
# the official app seems to use `distanceToEmptyBattery`.
- # In issue #150213, a user described to behavior as follows:
+ # In issue #150213, a user described the behavior as follows:
# - For a `distanceToEmptyBattery` of 250km, the `electricRange` was 150mi
# - For a `distanceToEmptyBattery` of 260km, the `electricRange` was 160mi
VolvoSensorDescription(
@@ -355,26 +355,18 @@ async def async_setup_entry(
) -> None:
"""Set up sensors."""
- entities: list[VolvoSensor] = []
- added_keys: set[str] = set()
-
- def _add_entity(
- coordinator: VolvoBaseCoordinator, description: VolvoSensorDescription
- ) -> None:
- entities.append(VolvoSensor(coordinator, description))
- added_keys.add(description.key)
-
- coordinators = entry.runtime_data
+ entities: dict[str, VolvoSensor] = {}
+ coordinators = entry.runtime_data.interval_coordinators
for coordinator in coordinators:
for description in _DESCRIPTIONS:
- if description.key in added_keys:
+ if description.key in entities:
continue
if description.api_field in coordinator.data:
- _add_entity(coordinator, description)
+ entities[description.key] = VolvoSensor(coordinator, description)
- async_add_entities(entities)
+ async_add_entities(entities.values())
class VolvoSensor(VolvoEntity, SensorEntity):
@@ -401,7 +393,7 @@ class VolvoSensor(VolvoEntity, SensorEntity):
native_value = str(native_value)
native_value = (
value_to_translation_key(native_value)
- if native_value.upper() != "UNSPECIFIED"
+ if native_value.upper() != API_NONE_VALUE
else None
)
diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json
index c429c106574..f10888ac325 100644
--- a/homeassistant/components/volvo/strings.json
+++ b/homeassistant/components/volvo/strings.json
@@ -1,4 +1,7 @@
{
+ "common": {
+ "pressure": "Pressure"
+ },
"config": {
"step": {
"pick_implementation": {
@@ -50,6 +53,140 @@
}
},
"entity": {
+ "binary_sensor": {
+ "brake_fluid_level_warning": {
+ "name": "Brake fluid"
+ },
+ "brake_light_center_warning": {
+ "name": "Brake light center"
+ },
+ "brake_light_left_warning": {
+ "name": "Brake light left"
+ },
+ "brake_light_right_warning": {
+ "name": "Brake light right"
+ },
+ "coolant_level_warning": {
+ "name": "Coolant level"
+ },
+ "daytime_running_light_left_warning": {
+ "name": "Daytime running light left"
+ },
+ "daytime_running_light_right_warning": {
+ "name": "Daytime running light right"
+ },
+ "door_front_left": {
+ "name": "Door front left"
+ },
+ "door_front_right": {
+ "name": "Door front right"
+ },
+ "door_rear_left": {
+ "name": "Door rear left"
+ },
+ "door_rear_right": {
+ "name": "Door rear right"
+ },
+ "engine_status": {
+ "name": "Engine status"
+ },
+ "fog_light_front_warning": {
+ "name": "Fog light front"
+ },
+ "fog_light_rear_warning": {
+ "name": "Fog light rear"
+ },
+ "hazard_lights_warning": {
+ "name": "Hazard lights"
+ },
+ "high_beam_left_warning": {
+ "name": "High beam left"
+ },
+ "high_beam_right_warning": {
+ "name": "High beam right"
+ },
+ "hood": {
+ "name": "Hood"
+ },
+ "low_beam_left_warning": {
+ "name": "Low beam left"
+ },
+ "low_beam_right_warning": {
+ "name": "Low beam right"
+ },
+ "oil_level_warning": {
+ "name": "Oil level"
+ },
+ "position_light_front_left_warning": {
+ "name": "Position light front left"
+ },
+ "position_light_front_right_warning": {
+ "name": "Position light front right"
+ },
+ "position_light_rear_left_warning": {
+ "name": "Position light rear left"
+ },
+ "position_light_rear_right_warning": {
+ "name": "Position light rear right"
+ },
+ "registration_plate_light_warning": {
+ "name": "Registration plate light"
+ },
+ "reverse_lights_warning": {
+ "name": "Reverse lights"
+ },
+ "side_mark_lights_warning": {
+ "name": "Side mark lights"
+ },
+ "sunroof": {
+ "name": "Sunroof"
+ },
+ "tailgate": {
+ "name": "Tailgate"
+ },
+ "tank_lid": {
+ "name": "Tank lid"
+ },
+ "turn_indication_front_left_warning": {
+ "name": "Turn indication front left"
+ },
+ "turn_indication_front_right_warning": {
+ "name": "Turn indication front right"
+ },
+ "turn_indication_rear_left_warning": {
+ "name": "Turn indication rear left"
+ },
+ "turn_indication_rear_right_warning": {
+ "name": "Turn indication rear right"
+ },
+ "tire_front_left": {
+ "name": "Tire front left"
+ },
+ "tire_front_right": {
+ "name": "Tire front right"
+ },
+ "tire_rear_left": {
+ "name": "Tire rear left"
+ },
+ "tire_rear_right": {
+ "name": "Tire rear right"
+ },
+ "washer_fluid_level_warning": {
+ "name": "Washer fluid"
+ },
+ "window_front_left": {
+ "name": "Window front left"
+ },
+ "window_front_right": {
+ "name": "Window front right"
+ },
+ "window_rear_left": {
+ "name": "Window rear left"
+ },
+ "window_rear_right": {
+ "name": "Window rear right"
+ }
+ },
"sensor": {
"availability": {
"name": "Car connection",
diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py
index 1a53f9a5dc4..6542f34b487 100644
--- a/homeassistant/components/volvooncall/__init__.py
+++ b/homeassistant/components/volvooncall/__init__.py
@@ -1,71 +1,46 @@
-"""Support for Volvo On Call."""
+"""The Volvo On Call integration."""
-from volvooncall import Connection
+from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_REGION,
- CONF_UNIT_SYSTEM,
- CONF_USERNAME,
-)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import issue_registry as ir
-from .const import (
- CONF_SCANDINAVIAN_MILES,
- DOMAIN,
- PLATFORMS,
- UNIT_SYSTEM_METRIC,
- UNIT_SYSTEM_SCANDINAVIAN_MILES,
-)
-from .coordinator import VolvoUpdateCoordinator
-from .models import VolvoData
+from .const import DOMAIN
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up the Volvo On Call component from a ConfigEntry."""
+ """Set up Volvo On Call integration."""
- # added CONF_UNIT_SYSTEM / deprecated CONF_SCANDINAVIAN_MILES in 2022.10 to support imperial units
- if CONF_UNIT_SYSTEM not in entry.data:
- new_conf = {**entry.data}
-
- scandinavian_miles: bool = entry.data[CONF_SCANDINAVIAN_MILES]
-
- new_conf[CONF_UNIT_SYSTEM] = (
- UNIT_SYSTEM_SCANDINAVIAN_MILES if scandinavian_miles else UNIT_SYSTEM_METRIC
- )
-
- hass.config_entries.async_update_entry(entry, data=new_conf)
-
- session = async_get_clientsession(hass)
-
- connection = Connection(
- session=session,
- username=entry.data[CONF_USERNAME],
- password=entry.data[CONF_PASSWORD],
- service_url=None,
- region=entry.data[CONF_REGION],
+ # Create repair issue pointing to the new volvo integration
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "volvooncall_deprecated",
+ breaks_in_ha_version="2026.3",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="volvooncall_deprecated",
)
- hass.data.setdefault(DOMAIN, {})
-
- volvo_data = VolvoData(hass, connection, entry)
-
- coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data)
-
- await coordinator.async_config_entry_first_refresh()
-
- hass.data[DOMAIN][entry.entry_id] = coordinator
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
- return unload_ok
+ # Only delete the repair issue if this is the last config entry for this domain
+ remaining_entries = [
+ config_entry
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.entry_id != entry.entry_id
+ ]
+
+ if not remaining_entries:
+ ir.async_delete_issue(
+ hass,
+ DOMAIN,
+ "volvooncall_deprecated",
+ )
+
+ return True
diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py
deleted file mode 100644
index 2ba8d19e3db..00000000000
--- a/homeassistant/components/volvooncall/binary_sensor.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""Support for VOC."""
-
-from __future__ import annotations
-
-from contextlib import suppress
-
-import voluptuous as vol
-from volvooncall.dashboard import Instrument
-
-from homeassistant.components.binary_sensor import (
- DEVICE_CLASSES_SCHEMA,
- BinarySensorEntity,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import DOMAIN, VOLVO_DISCOVERY_NEW
-from .coordinator import VolvoUpdateCoordinator
-from .entity import VolvoEntity
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Configure binary_sensors from a config entry created in the integrations UI."""
- coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- volvo_data = coordinator.volvo_data
-
- @callback
- def async_discover_device(instruments: list[Instrument]) -> None:
- """Discover and add a discovered Volvo On Call binary sensor."""
- async_add_entities(
- VolvoSensor(
- coordinator,
- instrument.vehicle.vin,
- instrument.component,
- instrument.attr,
- instrument.slug_attr,
- )
- for instrument in instruments
- if instrument.component == "binary_sensor"
- )
-
- async_discover_device([*volvo_data.instruments])
-
- config_entry.async_on_unload(
- async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
- )
-
-
-class VolvoSensor(VolvoEntity, BinarySensorEntity):
- """Representation of a Volvo sensor."""
-
- def __init__(
- self,
- coordinator: VolvoUpdateCoordinator,
- vin: str,
- component: str,
- attribute: str,
- slug_attr: str,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(vin, component, attribute, slug_attr, coordinator)
-
- with suppress(vol.Invalid):
- self._attr_device_class = DEVICE_CLASSES_SCHEMA(
- self.instrument.device_class
- )
-
- @property
- def is_on(self) -> bool | None:
- """Fetch from update coordinator."""
- if self.instrument.attr == "is_locked":
- return not self.instrument.is_on
- return self.instrument.is_on
diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py
index ccb0a7f62e1..e1aa95cb730 100644
--- a/homeassistant/components/volvooncall/config_flow.py
+++ b/homeassistant/components/volvooncall/config_flow.py
@@ -2,127 +2,21 @@
from __future__ import annotations
-from collections.abc import Mapping
-import logging
from typing import Any
-import voluptuous as vol
-from volvooncall import Connection
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_REGION,
- CONF_UNIT_SYSTEM,
- CONF_USERNAME,
-)
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-
-from .const import (
- CONF_MUTABLE,
- DOMAIN,
- UNIT_SYSTEM_IMPERIAL,
- UNIT_SYSTEM_METRIC,
- UNIT_SYSTEM_SCANDINAVIAN_MILES,
-)
-from .errors import InvalidAuth
-from .models import VolvoData
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DOMAIN
class VolvoOnCallConfigFlow(ConfigFlow, domain=DOMAIN):
- """VolvoOnCall config flow."""
+ """Handle a config flow for Volvo On Call."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle user step."""
- errors = {}
- defaults = {
- CONF_USERNAME: "",
- CONF_PASSWORD: "",
- CONF_REGION: None,
- CONF_MUTABLE: True,
- CONF_UNIT_SYSTEM: UNIT_SYSTEM_METRIC,
- }
+ """Handle the initial step."""
- if user_input is not None:
- await self.async_set_unique_id(user_input[CONF_USERNAME])
-
- if self.source != SOURCE_REAUTH:
- self._abort_if_unique_id_configured()
-
- try:
- await self.is_valid(user_input)
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except Exception:
- _LOGGER.exception("Unhandled exception in user step")
- errors["base"] = "unknown"
- if not errors:
- 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=user_input[CONF_USERNAME], data=user_input
- )
- elif self.source == SOURCE_REAUTH:
- reauth_entry = self._get_reauth_entry()
- for key in defaults:
- defaults[key] = reauth_entry.data.get(key)
-
- user_schema = vol.Schema(
- {
- vol.Required(CONF_USERNAME, default=defaults[CONF_USERNAME]): str,
- vol.Required(CONF_PASSWORD, default=defaults[CONF_PASSWORD]): str,
- vol.Required(CONF_REGION, default=defaults[CONF_REGION]): vol.In(
- {"na": "North America", "cn": "China", None: "Rest of world"}
- ),
- vol.Optional(
- CONF_UNIT_SYSTEM, default=defaults[CONF_UNIT_SYSTEM]
- ): vol.In(
- {
- UNIT_SYSTEM_METRIC: "Metric",
- UNIT_SYSTEM_SCANDINAVIAN_MILES: (
- "Metric with Scandinavian Miles"
- ),
- UNIT_SYSTEM_IMPERIAL: "Imperial",
- }
- ),
- vol.Optional(CONF_MUTABLE, default=defaults[CONF_MUTABLE]): bool,
- },
- )
-
- return self.async_show_form(
- step_id="user", data_schema=user_schema, errors=errors
- )
-
- async def async_step_reauth(
- self, entry_data: Mapping[str, Any]
- ) -> ConfigFlowResult:
- """Perform reauth upon an API authentication error."""
- return await self.async_step_user()
-
- async def is_valid(self, user_input):
- """Check for user input errors."""
-
- session = async_get_clientsession(self.hass)
-
- region: str | None = user_input.get(CONF_REGION)
-
- connection = Connection(
- session=session,
- username=user_input[CONF_USERNAME],
- password=user_input[CONF_PASSWORD],
- service_url=None,
- region=region,
- )
-
- test_volvo_data = VolvoData(self.hass, connection, user_input)
-
- await test_volvo_data.auth_is_valid()
+ return self.async_abort(reason="deprecated")
diff --git a/homeassistant/components/volvooncall/const.py b/homeassistant/components/volvooncall/const.py
index 4c969669af6..e04de08008b 100644
--- a/homeassistant/components/volvooncall/const.py
+++ b/homeassistant/components/volvooncall/const.py
@@ -1,66 +1,3 @@
-"""Constants for volvooncall."""
-
-from datetime import timedelta
+"""Constants for the Volvo On Call integration."""
DOMAIN = "volvooncall"
-
-DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
-
-CONF_SERVICE_URL = "service_url"
-CONF_SCANDINAVIAN_MILES = "scandinavian_miles"
-CONF_MUTABLE = "mutable"
-
-UNIT_SYSTEM_SCANDINAVIAN_MILES = "scandinavian_miles"
-UNIT_SYSTEM_METRIC = "metric"
-UNIT_SYSTEM_IMPERIAL = "imperial"
-
-PLATFORMS = {
- "sensor": "sensor",
- "binary_sensor": "binary_sensor",
- "lock": "lock",
- "device_tracker": "device_tracker",
- "switch": "switch",
-}
-
-RESOURCES = [
- "position",
- "lock",
- "heater",
- "odometer",
- "trip_meter1",
- "trip_meter2",
- "average_speed",
- "fuel_amount",
- "fuel_amount_level",
- "average_fuel_consumption",
- "distance_to_empty",
- "washer_fluid_level",
- "brake_fluid",
- "service_warning_status",
- "bulb_failures",
- "battery_range",
- "battery_level",
- "time_to_fully_charged",
- "battery_charge_status",
- "engine_start",
- "last_trip",
- "is_engine_running",
- "doors_hood_open",
- "doors_tailgate_open",
- "doors_front_left_door_open",
- "doors_front_right_door_open",
- "doors_rear_left_door_open",
- "doors_rear_right_door_open",
- "windows_front_left_window_open",
- "windows_front_right_window_open",
- "windows_rear_left_window_open",
- "windows_rear_right_window_open",
- "tyre_pressure_front_left_tyre_pressure",
- "tyre_pressure_front_right_tyre_pressure",
- "tyre_pressure_rear_left_tyre_pressure",
- "tyre_pressure_rear_right_tyre_pressure",
- "any_door_open",
- "any_window_open",
-]
-
-VOLVO_DISCOVERY_NEW = "volvo_discovery_new"
diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py
deleted file mode 100644
index 2c3e2ba365f..00000000000
--- a/homeassistant/components/volvooncall/coordinator.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Support for Volvo On Call."""
-
-import asyncio
-import logging
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-
-from .const import DEFAULT_UPDATE_INTERVAL
-from .models import VolvoData
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class VolvoUpdateCoordinator(DataUpdateCoordinator[None]):
- """Volvo coordinator."""
-
- config_entry: ConfigEntry
-
- def __init__(
- self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData
- ) -> None:
- """Initialize the data update coordinator."""
-
- super().__init__(
- hass,
- _LOGGER,
- config_entry=config_entry,
- name="volvooncall",
- update_interval=DEFAULT_UPDATE_INTERVAL,
- )
-
- self.volvo_data = volvo_data
-
- async def _async_update_data(self) -> None:
- """Fetch data from API endpoint."""
-
- async with asyncio.timeout(10):
- await self.volvo_data.update()
diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py
deleted file mode 100644
index 018acb02d49..00000000000
--- a/homeassistant/components/volvooncall/device_tracker.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Support for tracking a Volvo."""
-
-from __future__ import annotations
-
-from volvooncall.dashboard import Instrument
-
-from homeassistant.components.device_tracker import TrackerEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import DOMAIN, VOLVO_DISCOVERY_NEW
-from .coordinator import VolvoUpdateCoordinator
-from .entity import VolvoEntity
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Configure device_trackers from a config entry created in the integrations UI."""
- coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- volvo_data = coordinator.volvo_data
-
- @callback
- def async_discover_device(instruments: list[Instrument]) -> None:
- """Discover and add a discovered Volvo On Call device tracker."""
- async_add_entities(
- VolvoTrackerEntity(
- instrument.vehicle.vin,
- instrument.component,
- instrument.attr,
- instrument.slug_attr,
- coordinator,
- )
- for instrument in instruments
- if instrument.component == "device_tracker"
- )
-
- async_discover_device([*volvo_data.instruments])
-
- config_entry.async_on_unload(
- async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
- )
-
-
-class VolvoTrackerEntity(VolvoEntity, TrackerEntity):
- """A tracked Volvo vehicle."""
-
- @property
- def latitude(self) -> float | None:
- """Return latitude value of the device."""
- latitude, _ = self._get_pos()
- return latitude
-
- @property
- def longitude(self) -> float | None:
- """Return longitude value of the device."""
- _, longitude = self._get_pos()
- return longitude
-
- def _get_pos(self) -> tuple[float, float]:
- volvo_data = self.coordinator.volvo_data
- instrument = volvo_data.instrument(
- self.vin, self.component, self.attribute, self.slug_attr
- )
-
- latitude, longitude, _, _, _ = instrument.state
-
- return (float(latitude), float(longitude))
diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py
deleted file mode 100644
index 5a1194e8b1a..00000000000
--- a/homeassistant/components/volvooncall/entity.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""Support for Volvo On Call."""
-
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-
-from .const import DOMAIN
-from .coordinator import VolvoUpdateCoordinator
-
-
-class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]):
- """Base class for all VOC entities."""
-
- def __init__(
- self,
- vin: str,
- component: str,
- attribute: str,
- slug_attr: str,
- coordinator: VolvoUpdateCoordinator,
- ) -> None:
- """Initialize the entity."""
- super().__init__(coordinator)
-
- self.vin = vin
- self.component = component
- self.attribute = attribute
- self.slug_attr = slug_attr
-
- @property
- def instrument(self):
- """Return corresponding instrument."""
- return self.coordinator.volvo_data.instrument(
- self.vin, self.component, self.attribute, self.slug_attr
- )
-
- @property
- def icon(self):
- """Return the icon."""
- return self.instrument.icon
-
- @property
- def vehicle(self):
- """Return vehicle."""
- return self.instrument.vehicle
-
- @property
- def _entity_name(self):
- return self.instrument.name
-
- @property
- def _vehicle_name(self):
- return self.coordinator.volvo_data.vehicle_name(self.vehicle)
-
- @property
- def name(self):
- """Return full name of the entity."""
- return f"{self._vehicle_name} {self._entity_name}"
-
- @property
- def assumed_state(self) -> bool:
- """Return true if unable to access real state of entity."""
- return True
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return a inique set of attributes for each vehicle."""
- return DeviceInfo(
- identifiers={(DOMAIN, self.vehicle.vin)},
- name=self._vehicle_name,
- model=self.vehicle.vehicle_type,
- manufacturer="Volvo",
- )
-
- @property
- def extra_state_attributes(self):
- """Return device specific state attributes."""
- return dict(
- self.instrument.attributes,
- model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}",
- )
-
- @property
- def unique_id(self) -> str:
- """Return a unique ID."""
- slug_override = ""
- if self.instrument.slug_override is not None:
- slug_override = f"-{self.instrument.slug_override}"
- return f"{self.vin}-{self.component}-{self.attribute}{slug_override}"
diff --git a/homeassistant/components/volvooncall/errors.py b/homeassistant/components/volvooncall/errors.py
deleted file mode 100644
index 3736c5b9290..00000000000
--- a/homeassistant/components/volvooncall/errors.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Exceptions specific to volvooncall."""
-
-from homeassistant.exceptions import HomeAssistantError
-
-
-class InvalidAuth(HomeAssistantError):
- """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py
deleted file mode 100644
index 75b54e9dbbc..00000000000
--- a/homeassistant/components/volvooncall/lock.py
+++ /dev/null
@@ -1,80 +0,0 @@
-"""Support for Volvo On Call locks."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from volvooncall.dashboard import Instrument, Lock
-
-from homeassistant.components.lock import LockEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import DOMAIN, VOLVO_DISCOVERY_NEW
-from .coordinator import VolvoUpdateCoordinator
-from .entity import VolvoEntity
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Configure locks from a config entry created in the integrations UI."""
- coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- volvo_data = coordinator.volvo_data
-
- @callback
- def async_discover_device(instruments: list[Instrument]) -> None:
- """Discover and add a discovered Volvo On Call lock."""
- async_add_entities(
- VolvoLock(
- coordinator,
- instrument.vehicle.vin,
- instrument.component,
- instrument.attr,
- instrument.slug_attr,
- )
- for instrument in instruments
- if instrument.component == "lock"
- )
-
- async_discover_device([*volvo_data.instruments])
-
- config_entry.async_on_unload(
- async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
- )
-
-
-class VolvoLock(VolvoEntity, LockEntity):
- """Represents a car lock."""
-
- instrument: Lock
-
- def __init__(
- self,
- coordinator: VolvoUpdateCoordinator,
- vin: str,
- component: str,
- attribute: str,
- slug_attr: str,
- ) -> None:
- """Initialize the lock."""
- super().__init__(vin, component, attribute, slug_attr, coordinator)
-
- @property
- def is_locked(self) -> bool | None:
- """Determine if car is locked."""
- return self.instrument.is_locked
-
- async def async_lock(self, **kwargs: Any) -> None:
- """Lock the car."""
- await self.instrument.lock()
- await self.coordinator.async_request_refresh()
-
- async def async_unlock(self, **kwargs: Any) -> None:
- """Unlock the car."""
- await self.instrument.unlock()
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json
index 89a35ecde1d..b158cf7ed80 100644
--- a/homeassistant/components/volvooncall/manifest.json
+++ b/homeassistant/components/volvooncall/manifest.json
@@ -1,10 +1,9 @@
{
"domain": "volvooncall",
"name": "Volvo On Call",
- "codeowners": ["@molobrakos"],
+ "codeowners": ["@molobrakos", "@svrooij"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/volvooncall",
"iot_class": "cloud_polling",
- "loggers": ["geopy", "hbmqtt", "volvooncall"],
- "requirements": ["volvooncall==0.10.3"]
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/volvooncall/models.py b/homeassistant/components/volvooncall/models.py
deleted file mode 100644
index 159379a908b..00000000000
--- a/homeassistant/components/volvooncall/models.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Support for Volvo On Call."""
-
-from aiohttp.client_exceptions import ClientResponseError
-from volvooncall import Connection
-from volvooncall.dashboard import Instrument
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_UNIT_SYSTEM
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.update_coordinator import UpdateFailed
-
-from .const import (
- CONF_MUTABLE,
- PLATFORMS,
- UNIT_SYSTEM_IMPERIAL,
- UNIT_SYSTEM_SCANDINAVIAN_MILES,
- VOLVO_DISCOVERY_NEW,
-)
-from .errors import InvalidAuth
-
-
-class VolvoData:
- """Hold component state."""
-
- def __init__(
- self,
- hass: HomeAssistant,
- connection: Connection,
- entry: ConfigEntry,
- ) -> None:
- """Initialize the component state."""
- self.hass = hass
- self.vehicles: set[str] = set()
- self.instruments: set[Instrument] = set()
- self.config_entry = entry
- self.connection = connection
-
- def instrument(self, vin, component, attr, slug_attr):
- """Return corresponding instrument."""
- return next(
- instrument
- for instrument in self.instruments
- if instrument.vehicle.vin == vin
- and instrument.component == component
- and instrument.attr == attr
- and instrument.slug_attr == slug_attr
- )
-
- def vehicle_name(self, vehicle):
- """Provide a friendly name for a vehicle."""
- if vehicle.registration_number and vehicle.registration_number != "UNKNOWN":
- return vehicle.registration_number
- if vehicle.vin:
- return vehicle.vin
- return "Volvo"
-
- def discover_vehicle(self, vehicle):
- """Load relevant platforms."""
- self.vehicles.add(vehicle.vin)
-
- dashboard = vehicle.dashboard(
- mutable=self.config_entry.data[CONF_MUTABLE],
- scandinavian_miles=(
- self.config_entry.data[CONF_UNIT_SYSTEM]
- == UNIT_SYSTEM_SCANDINAVIAN_MILES
- ),
- usa_units=(
- self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL
- ),
- )
-
- for instrument in (
- instrument
- for instrument in dashboard.instruments
- if instrument.component in PLATFORMS
- ):
- self.instruments.add(instrument)
- async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument])
-
- async def update(self):
- """Update status from the online service."""
- try:
- await self.connection.update(journal=True)
- except ClientResponseError as ex:
- if ex.status == 401:
- raise ConfigEntryAuthFailed(ex) from ex
- raise UpdateFailed(ex) from ex
-
- for vehicle in self.connection.vehicles:
- if vehicle.vin not in self.vehicles:
- self.discover_vehicle(vehicle)
-
- async def auth_is_valid(self):
- """Check if provided username/password/region authenticate."""
- try:
- await self.connection.get("customeraccounts")
- except ClientResponseError as exc:
- raise InvalidAuth from exc
diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py
deleted file mode 100644
index feb7248ccaf..00000000000
--- a/homeassistant/components/volvooncall/sensor.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Support for Volvo On Call sensors."""
-
-from __future__ import annotations
-
-from volvooncall.dashboard import Instrument
-
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import DOMAIN, VOLVO_DISCOVERY_NEW
-from .coordinator import VolvoUpdateCoordinator
-from .entity import VolvoEntity
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Configure sensors from a config entry created in the integrations UI."""
- coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- volvo_data = coordinator.volvo_data
-
- @callback
- def async_discover_device(instruments: list[Instrument]) -> None:
- """Discover and add a discovered Volvo On Call sensor."""
- async_add_entities(
- VolvoSensor(
- coordinator,
- instrument.vehicle.vin,
- instrument.component,
- instrument.attr,
- instrument.slug_attr,
- )
- for instrument in instruments
- if instrument.component == "sensor"
- )
-
- async_discover_device([*volvo_data.instruments])
-
- config_entry.async_on_unload(
- async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
- )
-
-
-class VolvoSensor(VolvoEntity, SensorEntity):
- """Representation of a Volvo sensor."""
-
- def __init__(
- self,
- coordinator: VolvoUpdateCoordinator,
- vin: str,
- component: str,
- attribute: str,
- slug_attr: str,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(vin, component, attribute, slug_attr, coordinator)
- self._update_value_and_unit()
-
- def _update_value_and_unit(self) -> None:
- self._attr_native_value = self.instrument.state
- self._attr_native_unit_of_measurement = self.instrument.unit
-
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
- self._update_value_and_unit()
- self.async_write_ha_state()
diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json
index 44b821b4b01..72a406273bd 100644
--- a/homeassistant/components/volvooncall/strings.json
+++ b/homeassistant/components/volvooncall/strings.json
@@ -2,22 +2,17 @@
"config": {
"step": {
"user": {
- "data": {
- "username": "[%key:common::config_flow::data::username%]",
- "password": "[%key:common::config_flow::data::password%]",
- "region": "Region",
- "unit_system": "Unit System",
- "mutable": "Allow Remote Start / Lock / etc."
- }
+ "description": "Volvo on Call is deprecated, use the Volvo integration"
}
},
- "error": {
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "unknown": "[%key:common::config_flow::error::unknown%]"
- },
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "deprecated": "Volvo On Call has been replaced by the Volvo integration. Please use the Volvo integration instead."
+ }
+ },
+ "issues": {
+ "volvooncall_deprecated": {
+ "title": "Volvo On Call has been replaced",
+ "description": "The Volvo On Call integration is deprecated and will be removed in 2026.3. Please use the Volvo integration instead.\n\nSteps:\n1. Remove this Volvo On Call integration.\n2. Add the Volvo integration through Settings > Devices & services > Add integration > Volvo.\n3. Follow the setup instructions to authenticate with your Volvo account."
}
}
}
diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py
deleted file mode 100644
index ff321577348..00000000000
--- a/homeassistant/components/volvooncall/switch.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Support for Volvo heater."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from volvooncall.dashboard import Instrument
-
-from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from .const import DOMAIN, VOLVO_DISCOVERY_NEW
-from .coordinator import VolvoUpdateCoordinator
-from .entity import VolvoEntity
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Configure binary_sensors from a config entry created in the integrations UI."""
- coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
- volvo_data = coordinator.volvo_data
-
- @callback
- def async_discover_device(instruments: list[Instrument]) -> None:
- """Discover and add a discovered Volvo On Call switch."""
- async_add_entities(
- VolvoSwitch(
- coordinator,
- instrument.vehicle.vin,
- instrument.component,
- instrument.attr,
- instrument.slug_attr,
- )
- for instrument in instruments
- if instrument.component == "switch"
- )
-
- async_discover_device([*volvo_data.instruments])
-
- config_entry.async_on_unload(
- async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
- )
-
-
-class VolvoSwitch(VolvoEntity, SwitchEntity):
- """Representation of a Volvo switch."""
-
- def __init__(
- self,
- coordinator: VolvoUpdateCoordinator,
- vin: str,
- component: str,
- attribute: str,
- slug_attr: str,
- ) -> None:
- """Initialize the switch."""
- super().__init__(vin, component, attribute, slug_attr, coordinator)
-
- @property
- def is_on(self):
- """Determine if switch is on."""
- return self.instrument.state
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn the switch on."""
- await self.instrument.turn_on()
- await self.coordinator.async_request_refresh()
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn the switch off."""
- await self.instrument.turn_off()
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py
deleted file mode 100644
index 0bfd09d590d..00000000000
--- a/homeassistant/components/vulcan/__init__.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""The Vulcan component."""
-
-from aiohttp import ClientConnectorError
-from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-
-from .const import DOMAIN
-
-PLATFORMS = [Platform.CALENDAR]
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up Uonet+ Vulcan integration."""
- hass.data.setdefault(DOMAIN, {})
- try:
- keystore = Keystore.load(entry.data["keystore"])
- account = Account.load(entry.data["account"])
- client = Vulcan(keystore, account, async_get_clientsession(hass))
- await client.select_student()
- students = await client.get_students()
- for student in students:
- if str(student.pupil.id) == str(entry.data["student_id"]):
- client.student = student
- break
- except UnauthorizedCertificateException as err:
- raise ConfigEntryAuthFailed("The certificate is not authorized.") from err
- except ClientConnectorError as err:
- raise ConfigEntryNotReady(
- f"Connection error - please check your internet connection: {err}"
- ) from err
- hass.data[DOMAIN][entry.entry_id] = client
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
- return True
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py
deleted file mode 100644
index c2ef8b70d46..00000000000
--- a/homeassistant/components/vulcan/calendar.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Support for Vulcan Calendar platform."""
-
-from __future__ import annotations
-
-from datetime import date, datetime, timedelta
-import logging
-from typing import cast
-from zoneinfo import ZoneInfo
-
-from aiohttp import ClientConnectorError
-from vulcan import UnauthorizedCertificateException
-
-from homeassistant.components.calendar import (
- ENTITY_ID_FORMAT,
- CalendarEntity,
- CalendarEvent,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-
-from . import DOMAIN
-from .fetch_data import get_lessons, get_student_info
-
-_LOGGER = logging.getLogger(__name__)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddConfigEntryEntitiesCallback,
-) -> None:
- """Set up the calendar platform for entity."""
- client = hass.data[DOMAIN][config_entry.entry_id]
- data = {
- "student_info": await get_student_info(
- client, config_entry.data.get("student_id")
- ),
- }
- async_add_entities(
- [
- VulcanCalendarEntity(
- client,
- data,
- generate_entity_id(
- ENTITY_ID_FORMAT,
- f"vulcan_calendar_{data['student_info']['full_name']}",
- hass=hass,
- ),
- )
- ],
- )
-
-
-class VulcanCalendarEntity(CalendarEntity):
- """A calendar entity."""
-
- _attr_has_entity_name = True
- _attr_translation_key = "calendar"
-
- def __init__(self, client, data, entity_id) -> None:
- """Create the Calendar entity."""
- self._event: CalendarEvent | None = None
- self.client = client
- self.entity_id = entity_id
- student_info = data["student_info"]
- self._attr_unique_id = f"vulcan_calendar_{student_info['id']}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"calendar_{student_info['id']}")},
- entry_type=DeviceEntryType.SERVICE,
- name=cast(str, student_info["full_name"]),
- model=(
- f"{student_info['full_name']} -"
- f" {student_info['class']} {student_info['school']}"
- ),
- manufacturer="Uonet +",
- configuration_url=(
- f"https://uonetplus.vulcan.net.pl/{student_info['symbol']}"
- ),
- )
-
- @property
- def event(self) -> CalendarEvent | None:
- """Return the next upcoming event."""
- return self._event
-
- async def async_get_events(
- self, hass: HomeAssistant, start_date: datetime, end_date: datetime
- ) -> list[CalendarEvent]:
- """Get all events in a specific time frame."""
- try:
- events = await get_lessons(
- self.client,
- date_from=start_date,
- date_to=end_date,
- )
- except UnauthorizedCertificateException as err:
- raise ConfigEntryAuthFailed(
- "The certificate is not authorized, please authorize integration again"
- ) from err
- except ClientConnectorError as err:
- if self.available:
- _LOGGER.warning(
- "Connection error - please check your internet connection: %s", err
- )
- events = []
-
- event_list = []
- for item in events:
- event = CalendarEvent(
- start=datetime.combine(
- item["date"], item["time"].from_, ZoneInfo("Europe/Warsaw")
- ),
- end=datetime.combine(
- item["date"], item["time"].to, ZoneInfo("Europe/Warsaw")
- ),
- summary=item["lesson"],
- location=item["room"],
- description=item["teacher"],
- )
-
- event_list.append(event)
-
- return event_list
-
- async def async_update(self) -> None:
- """Get the latest data."""
-
- try:
- events = await get_lessons(self.client)
-
- if not self.available:
- _LOGGER.warning("Restored connection with API")
- self._attr_available = True
-
- if events == []:
- events = await get_lessons(
- self.client,
- date_to=date.today() + timedelta(days=7),
- )
- if events == []:
- self._event = None
- return
- except UnauthorizedCertificateException as err:
- raise ConfigEntryAuthFailed(
- "The certificate is not authorized, please authorize integration again"
- ) from err
- except ClientConnectorError as err:
- if self.available:
- _LOGGER.warning(
- "Connection error - please check your internet connection: %s", err
- )
- self._attr_available = False
- return
-
- new_event = min(
- events,
- key=lambda d: (
- datetime.combine(d["date"], d["time"].to) < datetime.now(),
- abs(datetime.combine(d["date"], d["time"].to) - datetime.now()),
- ),
- )
- self._event = CalendarEvent(
- start=datetime.combine(
- new_event["date"], new_event["time"].from_, ZoneInfo("Europe/Warsaw")
- ),
- end=datetime.combine(
- new_event["date"], new_event["time"].to, ZoneInfo("Europe/Warsaw")
- ),
- summary=new_event["lesson"],
- location=new_event["room"],
- description=new_event["teacher"],
- )
diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py
deleted file mode 100644
index f02adba9f75..00000000000
--- a/homeassistant/components/vulcan/config_flow.py
+++ /dev/null
@@ -1,327 +0,0 @@
-"""Adds config flow for Vulcan."""
-
-from collections.abc import Mapping
-import logging
-from typing import TYPE_CHECKING, Any
-
-from aiohttp import ClientConnectionError
-import voluptuous as vol
-from vulcan import (
- Account,
- ExpiredTokenException,
- InvalidPINException,
- InvalidSymbolException,
- InvalidTokenException,
- Keystore,
- UnauthorizedCertificateException,
- Vulcan,
-)
-from vulcan.model import Student
-
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-
-from . import DOMAIN
-from .register import register
-
-_LOGGER = logging.getLogger(__name__)
-
-LOGIN_SCHEMA = {
- vol.Required(CONF_TOKEN): str,
- vol.Required(CONF_REGION): str,
- vol.Required(CONF_PIN): str,
-}
-
-
-class VulcanFlowHandler(ConfigFlow, domain=DOMAIN):
- """Handle a Uonet+ Vulcan config flow."""
-
- VERSION = 1
-
- account: Account
- keystore: Keystore
-
- def __init__(self) -> None:
- """Initialize config flow."""
- self.students: list[Student] | None = None
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle config flow."""
- if self._async_current_entries():
- return await self.async_step_add_next_config_entry()
-
- return await self.async_step_auth()
-
- async def async_step_auth(
- self,
- user_input: dict[str, str] | None = None,
- errors: dict[str, str] | None = None,
- ) -> ConfigFlowResult:
- """Authorize integration."""
-
- if user_input is not None:
- try:
- credentials = await register(
- user_input[CONF_TOKEN],
- user_input[CONF_REGION],
- user_input[CONF_PIN],
- )
- except InvalidSymbolException:
- errors = {"base": "invalid_symbol"}
- except InvalidTokenException:
- errors = {"base": "invalid_token"}
- except InvalidPINException:
- errors = {"base": "invalid_pin"}
- except ExpiredTokenException:
- errors = {"base": "expired_token"}
- except ClientConnectionError as err:
- errors = {"base": "cannot_connect"}
- _LOGGER.error("Connection error: %s", err)
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors = {"base": "unknown"}
- if not errors:
- account = credentials["account"]
- keystore = credentials["keystore"]
- client = Vulcan(keystore, account, async_get_clientsession(self.hass))
- students = await client.get_students()
-
- if len(students) > 1:
- self.account = account
- self.keystore = keystore
- self.students = students
- return await self.async_step_select_student()
- student = students[0]
- await self.async_set_unique_id(str(student.pupil.id))
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=f"{student.pupil.first_name} {student.pupil.last_name}",
- data={
- "student_id": str(student.pupil.id),
- "keystore": keystore.as_dict,
- "account": account.as_dict,
- },
- )
-
- return self.async_show_form(
- step_id="auth",
- data_schema=vol.Schema(LOGIN_SCHEMA),
- errors=errors,
- )
-
- async def async_step_select_student(
- self, user_input: dict[str, str] | None = None
- ) -> ConfigFlowResult:
- """Allow user to select student."""
- errors: dict[str, str] = {}
- students: dict[str, str] = {}
- if self.students is not None:
- for student in self.students:
- students[str(student.pupil.id)] = (
- f"{student.pupil.first_name} {student.pupil.last_name}"
- )
- if user_input is not None:
- if TYPE_CHECKING:
- assert self.keystore is not None
- student_id = user_input["student"]
- await self.async_set_unique_id(str(student_id))
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=students[student_id],
- data={
- "student_id": str(student_id),
- "keystore": self.keystore.as_dict,
- "account": self.account.as_dict,
- },
- )
-
- return self.async_show_form(
- step_id="select_student",
- data_schema=vol.Schema({vol.Required("student"): vol.In(students)}),
- errors=errors,
- )
-
- async def async_step_select_saved_credentials(
- self,
- user_input: dict[str, str] | None = None,
- errors: dict[str, str] | None = None,
- ) -> ConfigFlowResult:
- """Allow user to select saved credentials."""
-
- credentials: dict[str, Any] = {}
- for entry in self.hass.config_entries.async_entries(DOMAIN):
- credentials[entry.entry_id] = entry.data["account"]["UserName"]
-
- if user_input is not None:
- existing_entry = self.hass.config_entries.async_get_entry(
- user_input["credentials"]
- )
- if TYPE_CHECKING:
- assert existing_entry is not None
- keystore = Keystore.load(existing_entry.data["keystore"])
- account = Account.load(existing_entry.data["account"])
- client = Vulcan(keystore, account, async_get_clientsession(self.hass))
- try:
- students = await client.get_students()
- except UnauthorizedCertificateException:
- return await self.async_step_auth(
- errors={"base": "expired_credentials"}
- )
- except ClientConnectionError as err:
- _LOGGER.error("Connection error: %s", err)
- return await self.async_step_select_saved_credentials(
- errors={"base": "cannot_connect"}
- )
- except Exception:
- _LOGGER.exception("Unexpected exception")
- return await self.async_step_auth(errors={"base": "unknown"})
- if len(students) == 1:
- student = students[0]
- await self.async_set_unique_id(str(student.pupil.id))
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=f"{student.pupil.first_name} {student.pupil.last_name}",
- data={
- "student_id": str(student.pupil.id),
- "keystore": keystore.as_dict,
- "account": account.as_dict,
- },
- )
- self.account = account
- self.keystore = keystore
- self.students = students
- return await self.async_step_select_student()
-
- data_schema = {
- vol.Required(
- "credentials",
- ): vol.In(credentials),
- }
- return self.async_show_form(
- step_id="select_saved_credentials",
- data_schema=vol.Schema(data_schema),
- errors=errors,
- )
-
- async def async_step_add_next_config_entry(
- self, user_input: dict[str, bool] | None = None
- ) -> ConfigFlowResult:
- """Flow initialized when user is adding next entry of that integration."""
-
- existing_entries = self.hass.config_entries.async_entries(DOMAIN)
-
- errors: dict[str, str] = {}
-
- if user_input is not None:
- if not user_input["use_saved_credentials"]:
- return await self.async_step_auth()
- if len(existing_entries) > 1:
- return await self.async_step_select_saved_credentials()
- keystore = Keystore.load(existing_entries[0].data["keystore"])
- account = Account.load(existing_entries[0].data["account"])
- client = Vulcan(keystore, account, async_get_clientsession(self.hass))
- students = await client.get_students()
- existing_entry_ids = [
- entry.data["student_id"] for entry in existing_entries
- ]
- new_students = [
- student
- for student in students
- if str(student.pupil.id) not in existing_entry_ids
- ]
- if not new_students:
- return self.async_abort(reason="all_student_already_configured")
- if len(new_students) == 1:
- await self.async_set_unique_id(str(new_students[0].pupil.id))
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=(
- f"{new_students[0].pupil.first_name} {new_students[0].pupil.last_name}"
- ),
- data={
- "student_id": str(new_students[0].pupil.id),
- "keystore": keystore.as_dict,
- "account": account.as_dict,
- },
- )
- self.account = account
- self.keystore = keystore
- self.students = new_students
- return await self.async_step_select_student()
-
- data_schema = {
- vol.Required("use_saved_credentials", default=True): bool,
- }
- return self.async_show_form(
- step_id="add_next_config_entry",
- data_schema=vol.Schema(data_schema),
- errors=errors,
- )
-
- 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, str] | None = None
- ) -> ConfigFlowResult:
- """Reauthorize integration."""
- errors = {}
- if user_input is not None:
- try:
- credentials = await register(
- user_input[CONF_TOKEN],
- user_input[CONF_REGION],
- user_input[CONF_PIN],
- )
- except InvalidSymbolException:
- errors = {"base": "invalid_symbol"}
- except InvalidTokenException:
- errors = {"base": "invalid_token"}
- except InvalidPINException:
- errors = {"base": "invalid_pin"}
- except ExpiredTokenException:
- errors = {"base": "expired_token"}
- except ClientConnectionError as err:
- errors["base"] = "cannot_connect"
- _LOGGER.error("Connection error: %s", err)
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- if not errors:
- account = credentials["account"]
- keystore = credentials["keystore"]
- client = Vulcan(keystore, account, async_get_clientsession(self.hass))
- students = await client.get_students()
- existing_entries = self.hass.config_entries.async_entries(DOMAIN)
- matching_entries = False
- for student in students:
- for entry in existing_entries:
- if str(student.pupil.id) == str(entry.data["student_id"]):
- self.hass.config_entries.async_update_entry(
- entry,
- title=(
- f"{student.pupil.first_name} {student.pupil.last_name}"
- ),
- data={
- "student_id": str(student.pupil.id),
- "keystore": keystore.as_dict,
- "account": account.as_dict,
- },
- )
- await self.hass.config_entries.async_reload(entry.entry_id)
- matching_entries = True
- if not matching_entries:
- return self.async_abort(reason="no_matching_entries")
- return self.async_abort(reason="reauth_successful")
-
- return self.async_show_form(
- step_id="reauth_confirm",
- data_schema=vol.Schema(LOGIN_SCHEMA),
- errors=errors,
- )
diff --git a/homeassistant/components/vulcan/const.py b/homeassistant/components/vulcan/const.py
deleted file mode 100644
index 4f17d43c342..00000000000
--- a/homeassistant/components/vulcan/const.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Constants for the Vulcan integration."""
-
-DOMAIN = "vulcan"
diff --git a/homeassistant/components/vulcan/fetch_data.py b/homeassistant/components/vulcan/fetch_data.py
deleted file mode 100644
index cd82346d5b7..00000000000
--- a/homeassistant/components/vulcan/fetch_data.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""Support for fetching Vulcan data."""
-
-
-async def get_lessons(client, date_from=None, date_to=None):
- """Support for fetching Vulcan lessons."""
- changes = {}
- list_ans = []
- async for lesson in await client.data.get_changed_lessons(
- date_from=date_from, date_to=date_to
- ):
- temp_dict = {}
- _id = str(lesson.id)
- temp_dict["id"] = lesson.id
- temp_dict["number"] = lesson.time.position if lesson.time is not None else None
- temp_dict["lesson"] = (
- lesson.subject.name if lesson.subject is not None else None
- )
- temp_dict["room"] = lesson.room.code if lesson.room is not None else None
- temp_dict["changes"] = lesson.changes
- temp_dict["note"] = lesson.note
- temp_dict["reason"] = lesson.reason
- temp_dict["event"] = lesson.event
- temp_dict["group"] = lesson.group
- temp_dict["teacher"] = (
- lesson.teacher.display_name if lesson.teacher is not None else None
- )
- temp_dict["from_to"] = (
- lesson.time.displayed_time if lesson.time is not None else None
- )
-
- changes[str(_id)] = temp_dict
-
- async for lesson in await client.data.get_lessons(
- date_from=date_from, date_to=date_to
- ):
- temp_dict = {}
- temp_dict["id"] = lesson.id
- temp_dict["number"] = lesson.time.position
- temp_dict["time"] = lesson.time
- temp_dict["date"] = lesson.date.date
- temp_dict["lesson"] = (
- lesson.subject.name if lesson.subject is not None else None
- )
- if lesson.room is not None:
- temp_dict["room"] = lesson.room.code
- else:
- temp_dict["room"] = "-"
- temp_dict["visible"] = lesson.visible
- temp_dict["changes"] = lesson.changes
- temp_dict["group"] = lesson.group
- temp_dict["reason"] = None
- temp_dict["teacher"] = (
- lesson.teacher.display_name if lesson.teacher is not None else None
- )
- temp_dict["from_to"] = (
- lesson.time.displayed_time if lesson.time is not None else None
- )
- if temp_dict["changes"] is None:
- temp_dict["changes"] = ""
- elif temp_dict["changes"].type == 1:
- temp_dict["lesson"] = f"Lekcja odwołana ({temp_dict['lesson']})"
- temp_dict["changes_info"] = f"Lekcja odwołana ({temp_dict['lesson']})"
- if str(temp_dict["changes"].id) in changes:
- temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"]
- elif temp_dict["changes"].type == 2:
- temp_dict["lesson"] = f"{temp_dict['lesson']} (Zastępstwo)"
- temp_dict["teacher"] = changes[str(temp_dict["changes"].id)]["teacher"]
- if str(temp_dict["changes"].id) in changes:
- temp_dict["teacher"] = changes[str(temp_dict["changes"].id)]["teacher"]
- temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"]
- elif temp_dict["changes"].type == 4:
- temp_dict["lesson"] = f"Lekcja odwołana ({temp_dict['lesson']})"
- if str(temp_dict["changes"].id) in changes:
- temp_dict["reason"] = changes[str(temp_dict["changes"].id)]["reason"]
- if temp_dict["visible"]:
- list_ans.append(temp_dict)
-
- return list_ans
-
-
-async def get_student_info(client, student_id):
- """Support for fetching Student info by student id."""
- student_info = {}
- for student in await client.get_students():
- if str(student.pupil.id) == str(student_id):
- student_info["first_name"] = student.pupil.first_name
- if student.pupil.second_name:
- student_info["second_name"] = student.pupil.second_name
- student_info["last_name"] = student.pupil.last_name
- student_info["full_name"] = (
- f"{student.pupil.first_name} {student.pupil.last_name}"
- )
- student_info["id"] = student.pupil.id
- student_info["class"] = student.class_
- student_info["school"] = student.school.name
- student_info["symbol"] = student.symbol
- break
- return student_info
diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json
deleted file mode 100644
index f9385262f05..00000000000
--- a/homeassistant/components/vulcan/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "domain": "vulcan",
- "name": "Uonet+ Vulcan",
- "codeowners": ["@Antoni-Czaplicki"],
- "config_flow": true,
- "documentation": "https://www.home-assistant.io/integrations/vulcan",
- "iot_class": "cloud_polling",
- "requirements": ["vulcan-api==2.4.2"]
-}
diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py
deleted file mode 100644
index a3dec97f622..00000000000
--- a/homeassistant/components/vulcan/register.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Support for register Vulcan account."""
-
-from typing import Any
-
-from vulcan import Account, Keystore
-
-
-async def register(token: str, symbol: str, pin: str) -> dict[str, Any]:
- """Register integration and save credentials."""
- keystore = await Keystore.create(device_model="Home Assistant")
- account = await Account.register(keystore, token, symbol, pin)
- return {"account": account, "keystore": keystore}
diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json
deleted file mode 100644
index d8344cbdeec..00000000000
--- a/homeassistant/components/vulcan/strings.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "config": {
- "abort": {
- "already_configured": "That student has already been added.",
- "all_student_already_configured": "All students have already been added.",
- "reauth_successful": "Reauth successful",
- "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration."
- },
- "error": {
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]",
- "expired_token": "Expired token - please generate a new token",
- "invalid_pin": "Invalid PIN",
- "invalid_symbol": "Invalid symbol",
- "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
- },
- "step": {
- "auth": {
- "description": "Log in to your Vulcan Account using mobile app registration page.",
- "data": {
- "token": "Token",
- "region": "Symbol",
- "pin": "[%key:common::config_flow::data::pin%]"
- }
- },
- "reauth_confirm": {
- "description": "[%key:component::vulcan::config::step::auth::description%]",
- "data": {
- "token": "Token",
- "region": "[%key:component::vulcan::config::step::auth::data::region%]",
- "pin": "[%key:common::config_flow::data::pin%]"
- }
- },
- "select_student": {
- "description": "Select student, you can add more students by adding integration again.",
- "data": {
- "student_name": "Select student"
- }
- },
- "select_saved_credentials": {
- "description": "Select saved credentials.",
- "data": {
- "credentials": "Login"
- }
- },
- "add_next_config_entry": {
- "description": "Add another student.",
- "data": {
- "use_saved_credentials": "Use saved credentials"
- }
- }
- }
- },
- "entity": {
- "calendar": {
- "calendar": {
- "name": "[%key:component::calendar::title%]"
- }
- }
- }
-}
diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py
deleted file mode 100644
index 66527bf458e..00000000000
--- a/homeassistant/components/vultr/__init__.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Support for Vultr."""
-
-from datetime import timedelta
-import logging
-
-import voluptuous as vol
-from vultr import Vultr as VultrAPI
-
-from homeassistant.components import persistent_notification
-from homeassistant.const import CONF_API_KEY, Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.typing import ConfigType
-from homeassistant.util import Throttle
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_AUTO_BACKUPS = "auto_backups"
-ATTR_ALLOWED_BANDWIDTH = "allowed_bandwidth_gb"
-ATTR_COST_PER_MONTH = "cost_per_month"
-ATTR_CURRENT_BANDWIDTH_USED = "current_bandwidth_gb"
-ATTR_CREATED_AT = "created_at"
-ATTR_DISK = "disk"
-ATTR_SUBSCRIPTION_ID = "subid"
-ATTR_SUBSCRIPTION_NAME = "label"
-ATTR_IPV4_ADDRESS = "ipv4_address"
-ATTR_IPV6_ADDRESS = "ipv6_address"
-ATTR_MEMORY = "memory"
-ATTR_OS = "os"
-ATTR_PENDING_CHARGES = "pending_charges"
-ATTR_REGION = "region"
-ATTR_VCPUS = "vcpus"
-
-CONF_SUBSCRIPTION = "subscription"
-
-DATA_VULTR = "data_vultr"
-DOMAIN = "vultr"
-
-NOTIFICATION_ID = "vultr_notification"
-NOTIFICATION_TITLE = "Vultr Setup"
-
-VULTR_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.Schema({vol.Required(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA
-)
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Vultr component."""
- api_key = config[DOMAIN].get(CONF_API_KEY)
-
- vultr = Vultr(api_key)
-
- try:
- vultr.update()
- except RuntimeError as ex:
- _LOGGER.error("Failed to make update API request because: %s", ex)
- persistent_notification.create(
- hass,
- f"Error: {ex}",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
-
- hass.data[DATA_VULTR] = vultr
- return True
-
-
-class Vultr:
- """Handle all communication with the Vultr API."""
-
- def __init__(self, api_key):
- """Initialize the Vultr connection."""
-
- self._api_key = api_key
- self.data = None
- self.api = VultrAPI(self._api_key)
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Use the data from Vultr API."""
- self.data = self.api.server_list()
-
- def _force_update(self):
- """Use the data from Vultr API."""
- self.data = self.api.server_list()
-
- def halt(self, subscription):
- """Halt a subscription (hard power off)."""
- self.api.server_halt(subscription)
- self._force_update()
-
- def start(self, subscription):
- """Start a subscription."""
- self.api.server_start(subscription)
- self._force_update()
diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py
deleted file mode 100644
index 3972de8a625..00000000000
--- a/homeassistant/components/vultr/binary_sensor.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Support for monitoring the state of Vultr subscriptions (VPS)."""
-
-from __future__ import annotations
-
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
- BinarySensorDeviceClass,
- BinarySensorEntity,
-)
-from homeassistant.const import CONF_NAME
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-from . import (
- ATTR_ALLOWED_BANDWIDTH,
- ATTR_AUTO_BACKUPS,
- ATTR_COST_PER_MONTH,
- ATTR_CREATED_AT,
- ATTR_DISK,
- ATTR_IPV4_ADDRESS,
- ATTR_IPV6_ADDRESS,
- ATTR_MEMORY,
- ATTR_OS,
- ATTR_REGION,
- ATTR_SUBSCRIPTION_ID,
- ATTR_SUBSCRIPTION_NAME,
- ATTR_VCPUS,
- CONF_SUBSCRIPTION,
- DATA_VULTR,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = "Vultr {}"
-PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_SUBSCRIPTION): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Vultr subscription (server) binary sensor."""
- vultr = hass.data[DATA_VULTR]
-
- subscription = config.get(CONF_SUBSCRIPTION)
- name = config.get(CONF_NAME)
-
- if subscription not in vultr.data:
- _LOGGER.error("Subscription %s not found", subscription)
- return
-
- add_entities([VultrBinarySensor(vultr, subscription, name)], True)
-
-
-class VultrBinarySensor(BinarySensorEntity):
- """Representation of a Vultr subscription sensor."""
-
- _attr_device_class = BinarySensorDeviceClass.POWER
-
- def __init__(self, vultr, subscription, name):
- """Initialize a new Vultr binary sensor."""
- self._vultr = vultr
- self._name = name
-
- self.subscription = subscription
- self.data = None
-
- @property
- def name(self):
- """Return the name of the sensor."""
- try:
- return self._name.format(self.data["label"])
- except (KeyError, TypeError):
- return self._name
-
- @property
- def icon(self):
- """Return the icon of this server."""
- return "mdi:server" if self.is_on else "mdi:server-off"
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self.data["power_status"] == "running"
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes of the Vultr subscription."""
- return {
- ATTR_ALLOWED_BANDWIDTH: self.data.get("allowed_bandwidth_gb"),
- ATTR_AUTO_BACKUPS: self.data.get("auto_backups"),
- ATTR_COST_PER_MONTH: self.data.get("cost_per_month"),
- ATTR_CREATED_AT: self.data.get("date_created"),
- ATTR_DISK: self.data.get("disk"),
- ATTR_IPV4_ADDRESS: self.data.get("main_ip"),
- ATTR_IPV6_ADDRESS: self.data.get("v6_main_ip"),
- ATTR_MEMORY: self.data.get("ram"),
- ATTR_OS: self.data.get("os"),
- ATTR_REGION: self.data.get("location"),
- ATTR_SUBSCRIPTION_ID: self.data.get("SUBID"),
- ATTR_SUBSCRIPTION_NAME: self.data.get("label"),
- ATTR_VCPUS: self.data.get("vcpu_count"),
- }
-
- def update(self) -> None:
- """Update state of sensor."""
- self._vultr.update()
- self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json
deleted file mode 100644
index 713485e7931..00000000000
--- a/homeassistant/components/vultr/manifest.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "domain": "vultr",
- "name": "Vultr",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/vultr",
- "iot_class": "cloud_polling",
- "loggers": ["vultr"],
- "quality_scale": "legacy",
- "requirements": ["vultr==0.1.2"]
-}
diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py
deleted file mode 100644
index c392c382cbd..00000000000
--- a/homeassistant/components/vultr/sensor.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Support for monitoring the state of Vultr Subscriptions."""
-
-from __future__ import annotations
-
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
- SensorDeviceClass,
- SensorEntity,
- SensorEntityDescription,
-)
-from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-from . import (
- ATTR_CURRENT_BANDWIDTH_USED,
- ATTR_PENDING_CHARGES,
- CONF_SUBSCRIPTION,
- DATA_VULTR,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = "Vultr {} {}"
-SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
- SensorEntityDescription(
- key=ATTR_CURRENT_BANDWIDTH_USED,
- name="Current Bandwidth Used",
- native_unit_of_measurement=UnitOfInformation.GIGABYTES,
- device_class=SensorDeviceClass.DATA_SIZE,
- icon="mdi:chart-histogram",
- ),
- SensorEntityDescription(
- key=ATTR_PENDING_CHARGES,
- name="Pending Charges",
- native_unit_of_measurement="US$",
- icon="mdi:currency-usd",
- ),
-)
-SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_SUBSCRIPTION): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All(
- cv.ensure_list, [vol.In(SENSOR_KEYS)]
- ),
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Vultr subscription (server) sensor."""
- vultr = hass.data[DATA_VULTR]
-
- subscription = config[CONF_SUBSCRIPTION]
- name = config[CONF_NAME]
- monitored_conditions = config[CONF_MONITORED_CONDITIONS]
-
- if subscription not in vultr.data:
- _LOGGER.error("Subscription %s not found", subscription)
- return
-
- entities = [
- VultrSensor(vultr, subscription, name, description)
- for description in SENSOR_TYPES
- if description.key in monitored_conditions
- ]
-
- add_entities(entities, True)
-
-
-class VultrSensor(SensorEntity):
- """Representation of a Vultr subscription sensor."""
-
- def __init__(
- self, vultr, subscription, name, description: SensorEntityDescription
- ) -> None:
- """Initialize a new Vultr sensor."""
- self.entity_description = description
- self._vultr = vultr
- self._name = name
-
- self.subscription = subscription
- self.data = None
-
- @property
- def name(self):
- """Return the name of the sensor."""
- try:
- return self._name.format(self.entity_description.name)
- except IndexError:
- try:
- return self._name.format(
- self.data["label"], self.entity_description.name
- )
- except (KeyError, TypeError):
- return self._name
-
- @property
- def native_value(self):
- """Return the value of this given sensor type."""
- try:
- return round(float(self.data.get(self.entity_description.key)), 2)
- except (TypeError, ValueError):
- return self.data.get(self.entity_description.key)
-
- def update(self) -> None:
- """Update state of sensor."""
- self._vultr.update()
- self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py
deleted file mode 100644
index 0b1f2247684..00000000000
--- a/homeassistant/components/vultr/switch.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""Support for interacting with Vultr subscriptions."""
-
-from __future__ import annotations
-
-import logging
-from typing import Any
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (
- PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
- SwitchEntity,
-)
-from homeassistant.const import CONF_NAME
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-from . import (
- ATTR_ALLOWED_BANDWIDTH,
- ATTR_AUTO_BACKUPS,
- ATTR_COST_PER_MONTH,
- ATTR_CREATED_AT,
- ATTR_DISK,
- ATTR_IPV4_ADDRESS,
- ATTR_IPV6_ADDRESS,
- ATTR_MEMORY,
- ATTR_OS,
- ATTR_REGION,
- ATTR_SUBSCRIPTION_ID,
- ATTR_SUBSCRIPTION_NAME,
- ATTR_VCPUS,
- CONF_SUBSCRIPTION,
- DATA_VULTR,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = "Vultr {}"
-PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_SUBSCRIPTION): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- }
-)
-
-
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up the Vultr subscription switch."""
- vultr = hass.data[DATA_VULTR]
-
- subscription = config.get(CONF_SUBSCRIPTION)
- name = config.get(CONF_NAME)
-
- if subscription not in vultr.data:
- _LOGGER.error("Subscription %s not found", subscription)
- return
-
- add_entities([VultrSwitch(vultr, subscription, name)], True)
-
-
-class VultrSwitch(SwitchEntity):
- """Representation of a Vultr subscription switch."""
-
- def __init__(self, vultr, subscription, name):
- """Initialize a new Vultr switch."""
- self._vultr = vultr
- self._name = name
-
- self.subscription = subscription
- self.data = None
-
- @property
- def name(self):
- """Return the name of the switch."""
- try:
- return self._name.format(self.data["label"])
- except (TypeError, KeyError):
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self.data["power_status"] == "running"
-
- @property
- def icon(self):
- """Return the icon of this server."""
- return "mdi:server" if self.is_on else "mdi:server-off"
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes of the Vultr subscription."""
- return {
- ATTR_ALLOWED_BANDWIDTH: self.data.get("allowed_bandwidth_gb"),
- ATTR_AUTO_BACKUPS: self.data.get("auto_backups"),
- ATTR_COST_PER_MONTH: self.data.get("cost_per_month"),
- ATTR_CREATED_AT: self.data.get("date_created"),
- ATTR_DISK: self.data.get("disk"),
- ATTR_IPV4_ADDRESS: self.data.get("main_ip"),
- ATTR_IPV6_ADDRESS: self.data.get("v6_main_ip"),
- ATTR_MEMORY: self.data.get("ram"),
- ATTR_OS: self.data.get("os"),
- ATTR_REGION: self.data.get("location"),
- ATTR_SUBSCRIPTION_ID: self.data.get("SUBID"),
- ATTR_SUBSCRIPTION_NAME: self.data.get("label"),
- ATTR_VCPUS: self.data.get("vcpu_count"),
- }
-
- def turn_on(self, **kwargs: Any) -> None:
- """Boot-up the subscription."""
- if self.data["power_status"] != "running":
- self._vultr.start(self.subscription)
-
- def turn_off(self, **kwargs: Any) -> None:
- """Halt the subscription."""
- if self.data["power_status"] == "running":
- self._vultr.halt(self.subscription)
-
- def update(self) -> None:
- """Get the latest data from the device and update the data."""
- self._vultr.update()
- self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py
index b2b2bac6480..f69755d05e8 100644
--- a/homeassistant/components/wake_on_lan/__init__.py
+++ b/homeassistant/components/wake_on_lan/__init__.py
@@ -69,15 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Wake on LAN component entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/wake_on_lan/config_flow.py b/homeassistant/components/wake_on_lan/config_flow.py
index fb54dd146e5..e6700c04604 100644
--- a/homeassistant/components/wake_on_lan/config_flow.py
+++ b/homeassistant/components/wake_on_lan/config_flow.py
@@ -73,6 +73,7 @@ class WakeonLanConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py
index 1059a41db53..cbe1aaa912a 100644
--- a/homeassistant/components/wallbox/const.py
+++ b/homeassistant/components/wallbox/const.py
@@ -3,7 +3,7 @@
from enum import StrEnum
DOMAIN = "wallbox"
-UPDATE_INTERVAL = 60
+UPDATE_INTERVAL = 90
BIDIRECTIONAL_MODEL_PREFIXES = ["QS"]
diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py
index 4e743b2106b..36785ee362a 100644
--- a/homeassistant/components/wallbox/coordinator.py
+++ b/homeassistant/components/wallbox/coordinator.py
@@ -209,7 +209,12 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from wallbox_connection_error
async def _async_update_data(self) -> dict[str, Any]:
- """Get new sensor data for Wallbox component."""
+ """Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations."""
+
+ self.update_interval = timedelta(
+ seconds=UPDATE_INTERVAL
+ * max(len(self.hass.config_entries.async_loaded_entries(DOMAIN)), 1)
+ )
return await self.hass.async_add_executor_job(self._get_data)
@_require_authentication
diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json
index 2bf72acb047..98d21dd9425 100644
--- a/homeassistant/components/waterfurnace/manifest.json
+++ b/homeassistant/components/waterfurnace/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
"quality_scale": "legacy",
- "requirements": ["waterfurnace==1.1.0"]
+ "requirements": ["waterfurnace==1.2.0"]
}
diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py
deleted file mode 100644
index 0130b53930b..00000000000
--- a/homeassistant/components/watson_iot/__init__.py
+++ /dev/null
@@ -1,227 +0,0 @@
-"""Support for the IBM Watson IoT Platform."""
-
-import logging
-import queue
-import threading
-import time
-
-from ibmiotf import MissingMessageEncoderException
-from ibmiotf.gateway import Client
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_DOMAINS,
- CONF_ENTITIES,
- CONF_EXCLUDE,
- CONF_ID,
- CONF_INCLUDE,
- CONF_TOKEN,
- CONF_TYPE,
- EVENT_HOMEASSISTANT_STOP,
- EVENT_STATE_CHANGED,
- STATE_UNAVAILABLE,
- STATE_UNKNOWN,
-)
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, state as state_helper
-from homeassistant.helpers.typing import ConfigType
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ORG = "organization"
-
-DOMAIN = "watson_iot"
-
-MAX_TRIES = 3
-
-RETRY_DELAY = 20
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.All(
- vol.Schema(
- {
- vol.Required(CONF_ORG): cv.string,
- vol.Required(CONF_TYPE): cv.string,
- vol.Required(CONF_ID): cv.string,
- vol.Required(CONF_TOKEN): cv.string,
- vol.Optional(CONF_EXCLUDE, default={}): vol.Schema(
- {
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]): vol.All(
- cv.ensure_list, [cv.string]
- ),
- }
- ),
- vol.Optional(CONF_INCLUDE, default={}): vol.Schema(
- {
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]): vol.All(
- cv.ensure_list, [cv.string]
- ),
- }
- ),
- }
- )
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Watson IoT Platform component."""
-
- conf = config[DOMAIN]
-
- include = conf[CONF_INCLUDE]
- exclude = conf[CONF_EXCLUDE]
- include_e = set(include[CONF_ENTITIES])
- include_d = set(include[CONF_DOMAINS])
- exclude_e = set(exclude[CONF_ENTITIES])
- exclude_d = set(exclude[CONF_DOMAINS])
-
- client_args = {
- "org": conf[CONF_ORG],
- "type": conf[CONF_TYPE],
- "id": conf[CONF_ID],
- "auth-method": "token",
- "auth-token": conf[CONF_TOKEN],
- }
- watson_gateway = Client(client_args)
-
- def event_to_json(event):
- """Add an event to the outgoing list."""
- state = event.data.get("new_state")
- if (
- state is None
- or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE)
- or state.entity_id in exclude_e
- or state.domain in exclude_d
- ):
- return None
-
- if (include_e and state.entity_id not in include_e) or (
- include_d and state.domain not in include_d
- ):
- return None
-
- try:
- _state_as_value = float(state.state)
- except ValueError:
- _state_as_value = None
-
- if _state_as_value is None:
- try:
- _state_as_value = float(state_helper.state_as_number(state))
- except ValueError:
- _state_as_value = None
-
- out_event = {
- "tags": {"domain": state.domain, "entity_id": state.object_id},
- "time": event.time_fired.isoformat(),
- "fields": {"state": state.state},
- }
- if _state_as_value is not None:
- out_event["fields"]["state_value"] = _state_as_value
-
- for key, value in state.attributes.items():
- if key != "unit_of_measurement":
- # If the key is already in fields
- if key in out_event["fields"]:
- key = f"{key}_"
- # For each value we try to cast it as float
- # But if we cannot do it we store the value
- # as string
- try:
- out_event["fields"][key] = float(value)
- except (ValueError, TypeError):
- out_event["fields"][key] = str(value)
-
- return out_event
-
- instance = hass.data[DOMAIN] = WatsonIOTThread(hass, watson_gateway, event_to_json)
- instance.start()
-
- def shutdown(event):
- """Shut down the thread."""
- instance.queue.put(None)
- instance.join()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
-
- return True
-
-
-class WatsonIOTThread(threading.Thread):
- """A threaded event handler class."""
-
- def __init__(self, hass, gateway, event_to_json):
- """Initialize the listener."""
- threading.Thread.__init__(self, name="WatsonIOT")
- self.queue = queue.Queue()
- self.gateway = gateway
- self.gateway.connect()
- self.event_to_json = event_to_json
- self.write_errors = 0
- self.shutdown = False
- hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener)
-
- @callback
- def _event_listener(self, event):
- """Listen for new messages on the bus and queue them for Watson IoT."""
- item = (time.monotonic(), event)
- self.queue.put(item)
-
- def get_events_json(self):
- """Return an event formatted for writing."""
- events = []
-
- try:
- if (item := self.queue.get()) is None:
- self.shutdown = True
- else:
- event_json = self.event_to_json(item[1])
- if event_json:
- events.append(event_json)
-
- except queue.Empty:
- pass
-
- return events
-
- def write_to_watson(self, events):
- """Write preprocessed events to watson."""
-
- for event in events:
- for retry in range(MAX_TRIES + 1):
- try:
- for field in event["fields"]:
- value = event["fields"][field]
- device_success = self.gateway.publishDeviceEvent(
- event["tags"]["domain"],
- event["tags"]["entity_id"],
- field,
- "json",
- value,
- )
- if not device_success:
- _LOGGER.error("Failed to publish message to Watson IoT")
- continue
- break
- except (MissingMessageEncoderException, OSError):
- if retry < MAX_TRIES:
- time.sleep(RETRY_DELAY)
- else:
- _LOGGER.exception("Failed to publish message to Watson IoT")
-
- def run(self):
- """Process incoming events."""
- while not self.shutdown:
- if event := self.get_events_json():
- self.write_to_watson(event)
- self.queue.task_done()
-
- def block_till_done(self):
- """Block till all events processed."""
- self.queue.join()
diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json
deleted file mode 100644
index a457dcc44b1..00000000000
--- a/homeassistant/components/watson_iot/manifest.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "domain": "watson_iot",
- "name": "IBM Watson IoT Platform",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/watson_iot",
- "iot_class": "cloud_push",
- "loggers": ["ibmiotf", "paho_mqtt"],
- "quality_scale": "legacy",
- "requirements": ["ibmiotf==0.3.4"]
-}
diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py
index 819ad90b354..3e30d15aebe 100644
--- a/homeassistant/components/weatherflow/__init__.py
+++ b/homeassistant/components/weatherflow/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.helpers.start import async_at_started
from .const import DOMAIN, LOGGER, format_dispatch_call
PLATFORMS = [
+ Platform.EVENT,
Platform.SENSOR,
]
diff --git a/homeassistant/components/weatherflow/event.py b/homeassistant/components/weatherflow/event.py
new file mode 100644
index 00000000000..05f7ecc2865
--- /dev/null
+++ b/homeassistant/components/weatherflow/event.py
@@ -0,0 +1,104 @@
+"""Event entities for the WeatherFlow integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pyweatherflowudp.device import EVENT_RAIN_START, EVENT_STRIKE, WeatherFlowDevice
+
+from homeassistant.components.event import EventEntity, EventEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN, LOGGER, format_dispatch_call
+
+
+@dataclass(frozen=True, kw_only=True)
+class WeatherFlowEventEntityDescription(EventEntityDescription):
+ """Describes a WeatherFlow event entity."""
+
+ wf_event: str
+ event_types: list[str]
+
+
+EVENT_DESCRIPTIONS: list[WeatherFlowEventEntityDescription] = [
+ WeatherFlowEventEntityDescription(
+ key="precip_start_event",
+ translation_key="precip_start_event",
+ event_types=["precipitation_start"],
+ wf_event=EVENT_RAIN_START,
+ ),
+ WeatherFlowEventEntityDescription(
+ key="lightning_strike_event",
+ translation_key="lightning_strike_event",
+ event_types=["lightning_strike"],
+ wf_event=EVENT_STRIKE,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up WeatherFlow event entities using config entry."""
+
+ @callback
+ def async_add_events(device: WeatherFlowDevice) -> None:
+ LOGGER.debug("Adding events for %s", device)
+ async_add_entities(
+ WeatherFlowEventEntity(device, description)
+ for description in EVENT_DESCRIPTIONS
+ )
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(
+ hass,
+ format_dispatch_call(config_entry),
+ async_add_events,
+ )
+ )
+
+
+class WeatherFlowEventEntity(EventEntity):
+ """Generic WeatherFlow event entity."""
+
+ _attr_has_entity_name = True
+ entity_description: WeatherFlowEventEntityDescription
+
+ def __init__(
+ self,
+ device: WeatherFlowDevice,
+ description: WeatherFlowEventEntityDescription,
+ ) -> None:
+ """Initialize the WeatherFlow event entity."""
+
+ self.device = device
+ self.entity_description = description
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.serial_number)},
+ manufacturer="WeatherFlow",
+ model=device.model,
+ name=device.serial_number,
+ sw_version=device.firmware_revision,
+ )
+ self._attr_unique_id = f"{device.serial_number}_{description.key}"
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to the configured WeatherFlow device event."""
+ self.async_on_remove(
+ self.device.on(self.entity_description.wf_event, self._handle_event)
+ )
+
+ @callback
+ def _handle_event(self, event) -> None:
+ self._trigger_event(
+ self.entity_description.event_types[0],
+ {},
+ )
+ self.async_write_ha_state()
diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json
index e0d2459b072..8e45060681e 100644
--- a/homeassistant/components/weatherflow/icons.json
+++ b/homeassistant/components/weatherflow/icons.json
@@ -38,6 +38,14 @@
"337.5": "mdi:arrow-up"
}
}
+ },
+ "event": {
+ "lightning_strike_event": {
+ "default": "mdi:weather-lightning"
+ },
+ "precip_start_event": {
+ "default": "mdi:weather-rainy"
+ }
}
}
}
diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json
index cf23f02d781..a4e3aac8ddd 100644
--- a/homeassistant/components/weatherflow/strings.json
+++ b/homeassistant/components/weatherflow/strings.json
@@ -79,6 +79,14 @@
"wind_lull": {
"name": "Wind lull"
}
+ },
+ "event": {
+ "lightning_strike_event": {
+ "name": "Lightning strike"
+ },
+ "precip_start_event": {
+ "name": "Precipitation start"
+ }
}
}
}
diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py
index ed3f8445110..94eba6ce5a4 100644
--- a/homeassistant/components/weatherflow_cloud/coordinator.py
+++ b/homeassistant/components/weatherflow_cloud/coordinator.py
@@ -2,7 +2,6 @@
from abc import ABC, abstractmethod
from datetime import timedelta
-from typing import Generic, TypeVar
from aiohttp import ClientResponseError
from weatherflow4py.api import WeatherFlowRestAPI
@@ -29,10 +28,8 @@ from homeassistant.util.ssl import client_context
from .const import DOMAIN, LOGGER
-T = TypeVar("T")
-
-class BaseWeatherFlowCoordinator(DataUpdateCoordinator[dict[int, T]], ABC, Generic[T]):
+class BaseWeatherFlowCoordinator[T](DataUpdateCoordinator[dict[int, T]], ABC):
"""Base class for WeatherFlow coordinators."""
def __init__(
@@ -106,9 +103,7 @@ class WeatherFlowCloudUpdateCoordinatorREST(
return self.data[station_id].station.name
-class BaseWebsocketCoordinator(
- BaseWeatherFlowCoordinator[dict[int, T | None]], ABC, Generic[T]
-):
+class BaseWebsocketCoordinator[T](BaseWeatherFlowCoordinator[dict[int, T | None]], ABC):
"""Base class for websocket coordinators."""
_event_type: EventType
diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py
index 907123561f7..3cc27a6f7e1 100644
--- a/homeassistant/components/webhook/trigger.py
+++ b/homeassistant/components/webhook/trigger.py
@@ -12,8 +12,9 @@ import voluptuous as vol
from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import (
DEFAULT_METHODS,
@@ -33,7 +34,7 @@ CONF_LOCAL_ONLY = "local_only"
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): "webhook",
- vol.Required(CONF_WEBHOOK_ID): cv.string,
+ vol.Required(CONF_WEBHOOK_ID): cv.template,
vol.Optional(CONF_ALLOWED_METHODS): vol.All(
cv.ensure_list,
[vol.All(vol.Upper, vol.In(SUPPORTED_METHODS))],
@@ -83,7 +84,13 @@ async def async_attach_trigger(
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Trigger based on incoming webhooks."""
- webhook_id: str = config[CONF_WEBHOOK_ID]
+ variables: TemplateVarsType | None = None
+ if trigger_info:
+ variables = trigger_info.get("variables")
+ webhook_id_template: Template = config[CONF_WEBHOOK_ID]
+ webhook_id: str = webhook_id_template.async_render(
+ variables, limited=True, parse_result=False
+ )
local_only = config.get(CONF_LOCAL_ONLY, True)
allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS)
job = HassJob(action)
diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py
index b63e5e14820..a15d63b31e6 100644
--- a/homeassistant/components/websocket_api/commands.py
+++ b/homeassistant/components/websocket_api/commands.py
@@ -473,6 +473,10 @@ def handle_subscribe_entities(
serialized_states = []
for state in states:
+ if entity_ids and state.entity_id not in entity_ids:
+ continue
+ if entity_filter and not entity_filter(state.entity_id):
+ continue
try:
serialized_states.append(state.as_compressed_state_json)
except (ValueError, TypeError):
@@ -647,9 +651,14 @@ async def handle_manifest_list(
hass, msg.get("integrations") or async_get_loaded_integrations(hass)
)
manifest_json_fragments: list[json_fragment] = []
- for int_or_exc in ints_or_excs.values():
+ for domain, int_or_exc in ints_or_excs.items():
if isinstance(int_or_exc, Exception):
- raise int_or_exc
+ _LOGGER.error(
+ "Unable to get manifest for integration %s: %s",
+ domain,
+ int_or_exc,
+ )
+ continue
manifest_json_fragments.append(int_or_exc.manifest_json_fragment)
connection.send_result(msg["id"], manifest_json_fragments)
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index 4250da149ad..0e9e0eb6933 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -37,6 +37,7 @@ from .messages import message_to_json_bytes
from .util import describe_request
CLOSE_MSG_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING}
+AUTH_MESSAGE_TIMEOUT = 10 # seconds
if TYPE_CHECKING:
from .connection import ActiveConnection
@@ -389,9 +390,11 @@ class WebSocketHandler:
# Auth Phase
try:
- msg = await self._wsock.receive(10)
+ msg = await self._wsock.receive(AUTH_MESSAGE_TIMEOUT)
except TimeoutError as err:
- raise Disconnect("Did not receive auth message within 10 seconds") from err
+ raise Disconnect(
+ f"Did not receive auth message within {AUTH_MESSAGE_TIMEOUT} seconds"
+ ) from err
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
raise Disconnect("Received close message during auth phase")
@@ -538,6 +541,14 @@ class WebSocketHandler:
finally:
if disconnect_warn is None:
logger.debug("%s: Disconnected", self.description)
+ elif connection is None:
+ # Auth phase disconnects (connection is None) should be logged at debug level
+ # as they can be from random port scanners or non-legitimate connections
+ logger.debug(
+ "%s: Disconnected during auth phase: %s",
+ self.description,
+ disconnect_warn,
+ )
else:
logger.warning(
"%s: Disconnected: %s", self.description, disconnect_warn
diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py
index d26f5764313..82f34882b91 100644
--- a/homeassistant/components/whirlpool/binary_sensor.py
+++ b/homeassistant/components/whirlpool/binary_sensor.py
@@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WhirlpoolConfigEntry
from .entity import WhirlpoolEntity
+PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(minutes=5)
diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py
index 0113d3c99d6..972d99c33ed 100644
--- a/homeassistant/components/whirlpool/climate.py
+++ b/homeassistant/components/whirlpool/climate.py
@@ -25,6 +25,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WhirlpoolConfigEntry
from .entity import WhirlpoolEntity
+PARALLEL_UPDATES = 1
+
AIRCON_MODE_MAP = {
AirconMode.Cool: HVACMode.COOL,
AirconMode.Heat: HVACMode.HEAT,
@@ -43,13 +45,6 @@ AIRCON_FANSPEED_MAP = {
FAN_MODE_TO_AIRCON_FANSPEED = {v: k for k, v in AIRCON_FANSPEED_MAP.items()}
-SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW, FAN_OFF]
-SUPPORTED_HVAC_MODES = [
- HVACMode.COOL,
- HVACMode.HEAT,
- HVACMode.FAN_ONLY,
- HVACMode.OFF,
-]
SUPPORTED_MAX_TEMP = 30
SUPPORTED_MIN_TEMP = 16
SUPPORTED_SWING_MODES = [SWING_HORIZONTAL, SWING_OFF]
@@ -71,9 +66,9 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
_appliance: Aircon
- _attr_fan_modes = SUPPORTED_FAN_MODES
_attr_name = None
- _attr_hvac_modes = SUPPORTED_HVAC_MODES
+ _attr_fan_modes = [*FAN_MODE_TO_AIRCON_FANSPEED.keys()]
+ _attr_hvac_modes = [HVACMode.OFF, *HVAC_MODE_TO_AIRCON_MODE.keys()]
_attr_max_temp = SUPPORTED_MAX_TEMP
_attr_min_temp = SUPPORTED_MIN_TEMP
_attr_supported_features = (
@@ -99,22 +94,15 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE))
+ AirConEntity._check_service_request(
+ await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE))
+ )
@property
def current_humidity(self) -> int:
"""Return the current humidity."""
return self._appliance.get_current_humidity()
- @property
- def target_humidity(self) -> int:
- """Return the humidity we try to reach."""
- return self._appliance.get_humidity()
-
- async def async_set_humidity(self, humidity: int) -> None:
- """Set new target humidity."""
- await self._appliance.set_humidity(humidity)
-
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, fan."""
@@ -127,13 +115,17 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode."""
if hvac_mode == HVACMode.OFF:
- await self._appliance.set_power_on(False)
+ AirConEntity._check_service_request(
+ await self._appliance.set_power_on(False)
+ )
return
mode = HVAC_MODE_TO_AIRCON_MODE[hvac_mode]
- await self._appliance.set_mode(mode)
+ AirConEntity._check_service_request(await self._appliance.set_mode(mode))
if not self._appliance.get_power_on():
- await self._appliance.set_power_on(True)
+ AirConEntity._check_service_request(
+ await self._appliance.set_power_on(True)
+ )
@property
def fan_mode(self) -> str:
@@ -143,9 +135,10 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
- if not (fanspeed := FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode)):
- raise ValueError(f"Invalid fan mode {fan_mode}")
- await self._appliance.set_fanspeed(fanspeed)
+ fanspeed = FAN_MODE_TO_AIRCON_FANSPEED[fan_mode]
+ AirConEntity._check_service_request(
+ await self._appliance.set_fanspeed(fanspeed)
+ )
@property
def swing_mode(self) -> str:
@@ -153,13 +146,15 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
return SWING_HORIZONTAL if self._appliance.get_h_louver_swing() else SWING_OFF
async def async_set_swing_mode(self, swing_mode: str) -> None:
- """Set new target temperature."""
- await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL)
+ """Set swing mode."""
+ AirConEntity._check_service_request(
+ await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL)
+ )
async def async_turn_on(self) -> None:
"""Turn device on."""
- await self._appliance.set_power_on(True)
+ AirConEntity._check_service_request(await self._appliance.set_power_on(True))
async def async_turn_off(self) -> None:
"""Turn device off."""
- await self._appliance.set_power_on(False)
+ AirConEntity._check_service_request(await self._appliance.set_power_on(False))
diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py
index a53fe0af263..95a065db2ca 100644
--- a/homeassistant/components/whirlpool/entity.py
+++ b/homeassistant/components/whirlpool/entity.py
@@ -1,18 +1,25 @@
"""Base entity for the Whirlpool integration."""
+import logging
+
from whirlpool.appliance import Appliance
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class WhirlpoolEntity(Entity):
"""Base class for Whirlpool entities."""
_attr_has_entity_name = True
_attr_should_poll = False
+ _unavailable_logged: bool = False
def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None:
"""Initialize the entity."""
@@ -28,13 +35,32 @@ class WhirlpoolEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register attribute updates callback."""
- self._appliance.register_attr_callback(self.async_write_ha_state)
+ self._appliance.register_attr_callback(self._async_attr_callback)
async def async_will_remove_from_hass(self) -> None:
"""Unregister attribute updates callback."""
- self._appliance.unregister_attr_callback(self.async_write_ha_state)
+ self._appliance.unregister_attr_callback(self._async_attr_callback)
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._appliance.get_online()
+ @callback
+ def _async_attr_callback(self) -> None:
+ _LOGGER.debug("Attribute update for entity %s", self.entity_id)
+ self._attr_available = self._appliance.get_online()
+
+ if not self._attr_available:
+ if not self._unavailable_logged:
+ _LOGGER.info("The entity %s is unavailable", self.entity_id)
+ self._unavailable_logged = True
+ elif self._unavailable_logged:
+ _LOGGER.info("The entity %s is back online", self.entity_id)
+ self._unavailable_logged = False
+
+ self.async_write_ha_state()
+
+ @staticmethod
+ def _check_service_request(result: bool) -> None:
+ """Check result of a request and raise HomeAssistantError if it failed."""
+ if not result:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="request_failed",
+ )
diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json
index 2712e6b2f64..3c2b28dbb20 100644
--- a/homeassistant/components/whirlpool/manifest.json
+++ b/homeassistant/components/whirlpool/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["whirlpool"],
- "quality_scale": "bronze",
- "requirements": ["whirlpool-sixth-sense==0.21.1"]
+ "quality_scale": "silver",
+ "requirements": ["whirlpool-sixth-sense==0.21.3"]
}
diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml
index 1323a064d5c..0348563fb6c 100644
--- a/homeassistant/components/whirlpool/quality_scale.yaml
+++ b/homeassistant/components/whirlpool/quality_scale.yaml
@@ -25,28 +25,18 @@ rules:
test-before-setup: done
unique-config-entry: done
# Silver
- action-exceptions:
- status: todo
- comment: |
- - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError
- - Current services raise ValueError and should raise ServiceValidationError instead.
+ action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration parameters
- docs-installation-parameters: todo
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
- log-when-unavailable: todo
- parallel-updates: todo
+ log-when-unavailable: done
+ parallel-updates: done
reauthentication-flow: done
- test-coverage:
- status: todo
- comment: |
- - Test helper init_integration() does not set a unique_id
- - Merge test_setup_http_exception and test_setup_auth_account_locked
- - The climate platform is at 94%
-
+ test-coverage: done
# Gold
devices: done
diagnostics: done
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index 1bb825cc18f..545ae67eaa1 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -24,6 +24,7 @@ from homeassistant.util.dt import utcnow
from . import WhirlpoolConfigEntry
from .entity import WhirlpoolEntity
+PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(minutes=5)
WASHER_TANK_FILL = {
diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json
index 9f214bf204f..3ab65d2e3aa 100644
--- a/homeassistant/components/whirlpool/strings.json
+++ b/homeassistant/components/whirlpool/strings.json
@@ -129,6 +129,9 @@
},
"appliances_fetch_failed": {
"message": "Failed to fetch appliances"
+ },
+ "request_failed": {
+ "message": "Request failed"
}
}
}
diff --git a/homeassistant/components/wled/analytics.py b/homeassistant/components/wled/analytics.py
new file mode 100644
index 00000000000..d801bfeb31f
--- /dev/null
+++ b/homeassistant/components/wled/analytics.py
@@ -0,0 +1,11 @@
+"""Analytics platform."""
+
+from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications
+from homeassistant.core import HomeAssistant
+
+
+async def async_modify_analytics(
+ hass: HomeAssistant, analytics_input: AnalyticsInput
+) -> AnalyticsModifications:
+ """Modify the analytics."""
+ return AnalyticsModifications(remove=True)
diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py
index e7255d478cb..6aa1fdcd437 100644
--- a/homeassistant/components/wmspro/cover.py
+++ b/homeassistant/components/wmspro/cover.py
@@ -6,10 +6,11 @@ from datetime import timedelta
from typing import Any
from wmspro.const import (
- WMS_WebControl_pro_API_actionDescription,
+ WMS_WebControl_pro_API_actionDescription as ACTION_DESC,
WMS_WebControl_pro_API_actionType,
WMS_WebControl_pro_API_responseType,
)
+from wmspro.destination import Destination
from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
@@ -32,11 +33,11 @@ async def async_setup_entry(
entities: list[WebControlProGenericEntity] = []
for dest in hub.dests.values():
- if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive):
+ if dest.hasAction(ACTION_DESC.AwningDrive):
entities.append(WebControlProAwning(config_entry.entry_id, dest))
- elif dest.hasAction(
- WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive
- ):
+ if dest.hasAction(ACTION_DESC.ValanceDrive):
+ entities.append(WebControlProValance(config_entry.entry_id, dest))
+ if dest.hasAction(ACTION_DESC.RollerShutterBlindDrive):
entities.append(WebControlProRollerShutter(config_entry.entry_id, dest))
async_add_entities(entities)
@@ -45,7 +46,7 @@ async def async_setup_entry(
class WebControlProCover(WebControlProGenericEntity, CoverEntity):
"""Base representation of a WMS based cover."""
- _drive_action_desc: WMS_WebControl_pro_API_actionDescription
+ _drive_action_desc: ACTION_DESC
_attr_name = None
@property
@@ -79,7 +80,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
action = self._dest.action(
- WMS_WebControl_pro_API_actionDescription.ManualCommand,
+ ACTION_DESC.ManualCommand,
WMS_WebControl_pro_API_actionType.Stop,
)
await action(responseType=WMS_WebControl_pro_API_responseType.Detailed)
@@ -89,13 +90,25 @@ class WebControlProAwning(WebControlProCover):
"""Representation of a WMS based awning."""
_attr_device_class = CoverDeviceClass.AWNING
- _drive_action_desc = WMS_WebControl_pro_API_actionDescription.AwningDrive
+ _drive_action_desc = ACTION_DESC.AwningDrive
+
+
+class WebControlProValance(WebControlProCover):
+ """Representation of a WMS based valance."""
+
+ _attr_translation_key = "valance"
+ _attr_device_class = CoverDeviceClass.SHADE
+ _drive_action_desc = ACTION_DESC.ValanceDrive
+
+ def __init__(self, config_entry_id: str, dest: Destination) -> None:
+ """Initialize the entity with destination channel."""
+ super().__init__(config_entry_id, dest)
+ if self._attr_unique_id:
+ self._attr_unique_id += "-valance"
class WebControlProRollerShutter(WebControlProCover):
"""Representation of a WMS based roller shutter or blind."""
_attr_device_class = CoverDeviceClass.SHUTTER
- _drive_action_desc = (
- WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive
- )
+ _drive_action_desc = ACTION_DESC.RollerShutterBlindDrive
diff --git a/homeassistant/components/workday/calendar.py b/homeassistant/components/workday/calendar.py
new file mode 100644
index 00000000000..e631ebb6e6a
--- /dev/null
+++ b/homeassistant/components/workday/calendar.py
@@ -0,0 +1,106 @@
+"""Workday Calendar."""
+
+from __future__ import annotations
+
+from datetime import date, datetime, timedelta
+
+from holidays import HolidayBase
+
+from homeassistant.components.calendar import CalendarEntity, CalendarEvent
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util import dt as dt_util
+
+from . import WorkdayConfigEntry
+from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS
+from .entity import BaseWorkdayEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: WorkdayConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Holiday Calendar config entry."""
+ days_offset: int = int(entry.options[CONF_OFFSET])
+ excludes: list[str] = entry.options[CONF_EXCLUDES]
+ sensor_name: str = entry.options[CONF_NAME]
+ workdays: list[str] = entry.options[CONF_WORKDAYS]
+ obj_holidays = entry.runtime_data
+
+ async_add_entities(
+ [
+ WorkdayCalendarEntity(
+ obj_holidays,
+ workdays,
+ excludes,
+ days_offset,
+ sensor_name,
+ entry.entry_id,
+ )
+ ],
+ )
+
+
+class WorkdayCalendarEntity(BaseWorkdayEntity, CalendarEntity):
+ """Representation of a Workday Calendar."""
+
+ def __init__(
+ self,
+ obj_holidays: HolidayBase,
+ workdays: list[str],
+ excludes: list[str],
+ days_offset: int,
+ name: str,
+ entry_id: str,
+ ) -> None:
+ """Initialize WorkdayCalendarEntity."""
+ super().__init__(
+ obj_holidays,
+ workdays,
+ excludes,
+ days_offset,
+ name,
+ entry_id,
+ )
+ self._attr_unique_id = entry_id
+ self._attr_event = None
+ self.event_list: list[CalendarEvent] = []
+ self._name = name
+
+ def update_data(self, now: datetime) -> None:
+ """Update data."""
+ event_list = []
+ start_date = date(now.year, 1, 1)
+ end_number_of_days = date(now.year + 1, 12, 31) - start_date
+ for i in range(end_number_of_days.days + 1):
+ future_date = start_date + timedelta(days=i)
+ if self.date_is_workday(future_date):
+ event = CalendarEvent(
+ summary=self._name,
+ start=future_date,
+ end=future_date,
+ )
+ event_list.append(event)
+ self.event_list = event_list
+
+ @property
+ def event(self) -> CalendarEvent | None:
+ """Return the next upcoming event."""
+ sorted_list: list[CalendarEvent] | None = (
+ sorted(self.event_list, key=lambda e: e.start) if self.event_list else None
+ )
+ if not sorted_list:
+ return None
+ return [d for d in sorted_list if d.start >= dt_util.utcnow().date()][0]
+
+ async def async_get_events(
+ self, hass: HomeAssistant, start_date: datetime, end_date: datetime
+ ) -> list[CalendarEvent]:
+ """Get all events in a specific time frame."""
+ return [
+ workday
+ for workday in self.event_list
+ if start_date.date() <= workday.start <= end_date.date()
+ ]
diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py
index 20d9040e527..f3b139b27c0 100644
--- a/homeassistant/components/workday/config_flow.py
+++ b/homeassistant/components/workday/config_flow.py
@@ -155,6 +155,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None:
subdiv=province,
years=year,
language=language,
+ categories=[PUBLIC, *user_input.get(CONF_CATEGORY, [])],
)
else:
diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py
index 76580ae642f..e8a6656d9e2 100644
--- a/homeassistant/components/workday/const.py
+++ b/homeassistant/components/workday/const.py
@@ -11,7 +11,7 @@ LOGGER = logging.getLogger(__package__)
ALLOWED_DAYS = [*WEEKDAYS, "holiday"]
DOMAIN = "workday"
-PLATFORMS = [Platform.BINARY_SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR]
CONF_PROVINCE = "province"
CONF_WORKDAYS = "workdays"
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index 0e336632b2e..c7a97ffb392 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.79"]
+ "requirements": ["holidays==0.81"]
}
diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json
index feedc52331b..e78ece25c21 100644
--- a/homeassistant/components/workday/strings.json
+++ b/homeassistant/components/workday/strings.json
@@ -212,6 +212,11 @@
}
}
}
+ },
+ "calendar": {
+ "workday": {
+ "name": "[%key:component::calendar::title%]"
+ }
}
},
"services": {
diff --git a/homeassistant/components/workday/util.py b/homeassistant/components/workday/util.py
index 726563febaf..9762e1b42fa 100644
--- a/homeassistant/components/workday/util.py
+++ b/homeassistant/components/workday/util.py
@@ -1,5 +1,7 @@
"""Helpers functions for the Workday component."""
+from __future__ import annotations
+
from datetime import date, timedelta
from functools import partial
from typing import TYPE_CHECKING
@@ -20,7 +22,7 @@ from .const import CONF_REMOVE_HOLIDAYS, DOMAIN, LOGGER
async def async_validate_country_and_province(
hass: HomeAssistant,
- entry: "WorkdayConfigEntry",
+ entry: WorkdayConfigEntry,
country: str | None,
province: str | None,
) -> None:
@@ -180,7 +182,7 @@ def get_holidays_object(
def add_remove_custom_holidays(
hass: HomeAssistant,
- entry: "WorkdayConfigEntry",
+ entry: WorkdayConfigEntry,
country: str | None,
calc_add_holidays: list[DateLike],
calc_remove_holidays: list[str],
diff --git a/homeassistant/components/worldclock/__init__.py b/homeassistant/components/worldclock/__init__.py
index ad01c45917a..c9bd5aa1e2e 100644
--- a/homeassistant/components/worldclock/__init__.py
+++ b/homeassistant/components/worldclock/__init__.py
@@ -10,7 +10,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Worldclock from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -18,8 +17,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload World clock config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py
index e91d2e40f63..f248d5de4c6 100644
--- a/homeassistant/components/worldclock/config_flow.py
+++ b/homeassistant/components/worldclock/config_flow.py
@@ -97,6 +97,7 @@ class WorldclockConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
+ options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json
index 39f5267006e..628a3e4d147 100644
--- a/homeassistant/components/wyoming/manifest.json
+++ b/homeassistant/components/wyoming/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "wyoming",
"name": "Wyoming Protocol",
- "codeowners": ["@balloob", "@synesthesiam"],
+ "codeowners": ["@synesthesiam"],
"config_flow": true,
"dependencies": [
"assist_satellite",
diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py
index c9829746d59..ee57abd769d 100644
--- a/homeassistant/components/xmpp/notify.py
+++ b/homeassistant/components/xmpp/notify.py
@@ -146,6 +146,8 @@ async def async_send_message( # noqa: C901
self.enable_starttls = use_tls
self.enable_direct_tls = use_tls
+ self.enable_plaintext = not use_tls
+ self["feature_mechanisms"].unencrypted_scram = not use_tls
self.use_ipv6 = False
self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail)
self.add_event_handler("session_start", self.start)
diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py
index 079c1dcd3dd..edf368ed8d0 100644
--- a/homeassistant/components/yale/lock.py
+++ b/homeassistant/components/yale/lock.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
import logging
from typing import Any
from aiohttp import ClientResponseError
-from yalexs.activity import ActivityType, ActivityTypes
-from yalexs.lock import Lock, LockStatus
+from yalexs.activity import ActivityType
+from yalexs.lock import Lock, LockOperation, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
@@ -50,30 +49,25 @@ class YaleLock(YaleEntity, RestoreEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
- if self._data.push_updates_connected:
- await self._data.async_lock_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_lock)
+ await self._perform_lock_operation(LockOperation.LOCK)
async def async_open(self, **kwargs: Any) -> None:
"""Open/unlatch the device."""
- if self._data.push_updates_connected:
- await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_unlatch)
+ await self._perform_lock_operation(LockOperation.OPEN)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
- if self._data.push_updates_connected:
- await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
- return
- await self._call_lock_operation(self._data.async_unlock)
+ await self._perform_lock_operation(LockOperation.UNLOCK)
- async def _call_lock_operation(
- self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
- ) -> None:
+ async def _perform_lock_operation(self, operation: LockOperation) -> None:
+ """Perform a lock operation."""
try:
- activities = await lock_operation(self._device_id)
+ activities = await self._data.async_operate_lock(
+ self._device_id,
+ operation,
+ self._data.push_updates_connected,
+ self._hyper_bridge,
+ )
except ClientResponseError as err:
if err.status == LOCK_JAMMED_ERR:
self._detail.lock_status = LockStatus.JAMMED
diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json
index 0397fab7705..4533e6fb49d 100644
--- a/homeassistant/components/yale/manifest.json
+++ b/homeassistant/components/yale/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
- "requirements": ["yalexs==8.12.0", "yalexs-ble==3.1.2"]
+ "requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
}
diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py
index 68d64494e41..8d3c298643c 100644
--- a/homeassistant/components/yalexs_ble/__init__.py
+++ b/homeassistant/components/yalexs_ble/__init__.py
@@ -19,6 +19,7 @@ from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from .config_cache import async_get_validated_config
from .const import (
CONF_ALWAYS_CONNECTED,
CONF_KEY,
@@ -96,13 +97,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) ->
)
try:
- await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
- except AuthError as ex:
- raise ConfigEntryAuthFailed(str(ex)) from ex
- except (YaleXSBLEError, TimeoutError) as ex:
- raise ConfigEntryNotReady(
- f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
- ) from ex
+ await _async_wait_for_first_update(push_lock, local_name)
+ except ConfigEntryAuthFailed:
+ # If key has rotated, try to fetch it from the cache
+ # and update
+ if (validated_config := async_get_validated_config(hass, address)) and (
+ validated_config.key != entry.data[CONF_KEY]
+ or validated_config.slot != entry.data[CONF_SLOT]
+ ):
+ assert shutdown_callback is not None
+ shutdown_callback()
+ push_lock.set_lock_key(validated_config.key, validated_config.slot)
+ shutdown_callback = await push_lock.start()
+ await _async_wait_for_first_update(push_lock, local_name)
+ # If we can use the cached key and slot, update the entry.
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ **entry.data,
+ CONF_KEY: validated_config.key,
+ CONF_SLOT: validated_config.slot,
+ },
+ )
+ else:
+ raise
entry.runtime_data = YaleXSBLEData(entry.title, push_lock, always_connected)
@@ -129,22 +147,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) ->
entry.async_on_unload(push_lock.register_callback(_async_state_changed))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(_async_update_listener))
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown)
)
return True
-async def _async_update_listener(
- hass: HomeAssistant, entry: YALEXSBLEConfigEntry
-) -> None:
- """Handle options update."""
- data = entry.runtime_data
- if entry.title != data.title or data.always_connected != entry.options.get(
- CONF_ALWAYS_CONNECTED
- ):
- await hass.config_entries.async_reload(entry.entry_id)
+async def _async_wait_for_first_update(push_lock: PushLock, local_name: str) -> None:
+ """Wait for the first update from the push lock."""
+ try:
+ await push_lock.wait_for_first_update(DEVICE_TIMEOUT)
+ except AuthError as ex:
+ raise ConfigEntryAuthFailed(str(ex)) from ex
+ except (YaleXSBLEError, TimeoutError) as ex:
+ raise ConfigEntryNotReady(
+ f"{ex}; Try moving the Bluetooth adapter closer to {local_name}"
+ ) from ex
async def async_unload_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool:
diff --git a/homeassistant/components/yalexs_ble/config_cache.py b/homeassistant/components/yalexs_ble/config_cache.py
new file mode 100644
index 00000000000..eccfbf3ea9e
--- /dev/null
+++ b/homeassistant/components/yalexs_ble/config_cache.py
@@ -0,0 +1,31 @@
+"""The Yale Access Bluetooth integration."""
+
+from __future__ import annotations
+
+from yalexs_ble import ValidatedLockConfig
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.util.hass_dict import HassKey
+
+CONFIG_CACHE: HassKey[dict[str, ValidatedLockConfig]] = HassKey(
+ "yalexs_ble_config_cache"
+)
+
+
+@callback
+def async_add_validated_config(
+ hass: HomeAssistant,
+ address: str,
+ config: ValidatedLockConfig,
+) -> None:
+ """Add a validated config."""
+ hass.data.setdefault(CONFIG_CACHE, {})[address] = config
+
+
+@callback
+def async_get_validated_config(
+ hass: HomeAssistant,
+ address: str,
+) -> ValidatedLockConfig | None:
+ """Get the config for a specific address."""
+ return hass.data.get(CONFIG_CACHE, {}).get(address)
diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py
index 0e1eabdf6b2..3fa8af678e5 100644
--- a/homeassistant/components/yalexs_ble/config_flow.py
+++ b/homeassistant/components/yalexs_ble/config_flow.py
@@ -26,13 +26,14 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
- OptionsFlow,
+ OptionsFlowWithReload,
)
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.typing import DiscoveryInfoType
+from .config_cache import async_add_validated_config, async_get_validated_config
from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
from .util import async_find_existing_service_info, human_readable_name
@@ -92,7 +93,10 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
None, discovery_info.name, discovery_info.address
),
}
- return await self.async_step_user()
+ if lock_cfg := async_get_validated_config(self.hass, discovery_info.address):
+ self._lock_cfg = lock_cfg
+ return await self.async_step_integration_discovery_confirm()
+ return await self.async_step_key_slot()
async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
@@ -105,6 +109,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info["key"],
discovery_info["slot"],
)
+ async_add_validated_config(self.hass, lock_cfg.address, lock_cfg)
address = lock_cfg.address
self.local_name = lock_cfg.local_name
@@ -232,6 +237,59 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_key_slot(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the key and slot step."""
+ errors: dict[str, str] = {}
+ discovery_info = self._discovery_info
+ assert discovery_info is not None
+ address = discovery_info.address
+ validated_config = async_get_validated_config(self.hass, address)
+
+ if user_input is not None or validated_config:
+ local_name = discovery_info.name
+ if validated_config:
+ key = validated_config.key
+ slot = validated_config.slot
+ title = validated_config.name
+ else:
+ assert user_input is not None
+ key = user_input[CONF_KEY]
+ slot = user_input[CONF_SLOT]
+ title = human_readable_name(None, local_name, address)
+ await self.async_set_unique_id(address, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+ if not (
+ errors := await async_validate_lock_or_error(
+ local_name, discovery_info.device, key, slot
+ )
+ ):
+ return self.async_create_entry(
+ title=title,
+ data={
+ CONF_LOCAL_NAME: discovery_info.name,
+ CONF_ADDRESS: discovery_info.address,
+ CONF_KEY: key,
+ CONF_SLOT: slot,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="key_slot",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_KEY): str,
+ vol.Required(CONF_SLOT): int,
+ }
+ ),
+ errors=errors,
+ description_placeholders={
+ "address": address,
+ "title": self._async_get_name_from_address(address),
+ },
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -241,47 +299,24 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self.active = True
address = user_input[CONF_ADDRESS]
- discovery_info = self._discovered_devices[address]
- local_name = discovery_info.name
- key = user_input[CONF_KEY]
- slot = user_input[CONF_SLOT]
- await self.async_set_unique_id(
- discovery_info.address, raise_on_progress=False
- )
- self._abort_if_unique_id_configured()
- if not (
- errors := await async_validate_lock_or_error(
- local_name, discovery_info.device, key, slot
- )
- ):
- return self.async_create_entry(
- title=local_name,
- data={
- CONF_LOCAL_NAME: discovery_info.name,
- CONF_ADDRESS: discovery_info.address,
- CONF_KEY: key,
- CONF_SLOT: slot,
- },
- )
+ self._discovery_info = self._discovered_devices[address]
+ return await self.async_step_key_slot()
- if discovery := self._discovery_info:
+ current_addresses = self._async_current_ids(include_ignore=False)
+ current_unique_names = {
+ entry.data.get(CONF_LOCAL_NAME)
+ for entry in self._async_current_entries()
+ if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
+ }
+ for discovery in async_discovered_service_info(self.hass):
+ if (
+ discovery.address in current_addresses
+ or discovery.name in current_unique_names
+ or discovery.address in self._discovered_devices
+ or YALE_MFR_ID not in discovery.manufacturer_data
+ ):
+ continue
self._discovered_devices[discovery.address] = discovery
- else:
- current_addresses = self._async_current_ids(include_ignore=False)
- current_unique_names = {
- entry.data.get(CONF_LOCAL_NAME)
- for entry in self._async_current_entries()
- if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
- }
- for discovery in async_discovered_service_info(self.hass):
- if (
- discovery.address in current_addresses
- or discovery.name in current_unique_names
- or discovery.address in self._discovered_devices
- or YALE_MFR_ID not in discovery.manufacturer_data
- ):
- continue
- self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
@@ -290,14 +325,12 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_ADDRESS): vol.In(
{
- service_info.address: (
- f"{service_info.name} ({service_info.address})"
+ service_info.address: self._async_get_name_from_address(
+ service_info.address
)
for service_info in self._discovered_devices.values()
}
- ),
- vol.Required(CONF_KEY): str,
- vol.Required(CONF_SLOT): int,
+ )
}
)
return self.async_show_form(
@@ -306,6 +339,18 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ @callback
+ def _async_get_name_from_address(self, address: str) -> str:
+ """Get the name of a device from its address."""
+ if validated_config := async_get_validated_config(self.hass, address):
+ return f"{validated_config.name} ({address})"
+ if address in self._discovered_devices:
+ service_info = self._discovered_devices[address]
+ return f"{service_info.name} ({service_info.address})"
+ assert self._discovery_info is not None
+ assert self._discovery_info.address == address
+ return f"{self._discovery_info.name} ({address})"
+
@staticmethod
@callback
def async_get_options_flow(
@@ -315,7 +360,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
return YaleXSBLEOptionsFlowHandler()
-class YaleXSBLEOptionsFlowHandler(OptionsFlow):
+class YaleXSBLEOptionsFlowHandler(OptionsFlowWithReload):
"""Handle YaleXSBLE options."""
async def async_step_init(
diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json
index 92d807d01f6..604ff34aa6f 100644
--- a/homeassistant/components/yalexs_ble/strings.json
+++ b/homeassistant/components/yalexs_ble/strings.json
@@ -3,18 +3,23 @@
"flow_title": "{name}",
"step": {
"user": {
- "description": "Check the documentation for how to find the offline key. If you are using the August cloud integration to obtain the key, you may need to reload the August cloud integration while the lock is in Bluetooth range.",
+ "description": "Select the device you want to set up over Bluetooth.",
+ "data": {
+ "address": "Bluetooth address"
+ }
+ },
+ "key_slot": {
+ "description": "Enter the key for the {title} lock with address {address}. If you are using the August or Yale cloud integration to obtain the key, you may be able to avoid this manual setup by reloading the August or Yale cloud integration while the lock is in Bluetooth range.",
"data": {
- "address": "Bluetooth address",
"key": "Offline Key (32-byte hex string)",
"slot": "Offline Key Slot (Integer between 0 and 255)"
}
},
"reauth_validate": {
- "description": "Enter the updated key for the {title} lock with address {address}. If you are using the August cloud integration to obtain the key, you may be able to avoid manual reauthentication by reloading the August cloud integration while the lock is in Bluetooth range.",
+ "description": "Enter the updated key for the {title} lock with address {address}. If you are using the August or Yale cloud integration to obtain the key, you may be able to avoid manual re-authentication by reloading the August or Yale cloud integration while the lock is in Bluetooth range.",
"data": {
- "key": "[%key:component::yalexs_ble::config::step::user::data::key%]",
- "slot": "[%key:component::yalexs_ble::config::step::user::data::slot%]"
+ "key": "[%key:component::yalexs_ble::config::step::key_slot::data::key%]",
+ "slot": "[%key:component::yalexs_ble::config::step::key_slot::data::slot%]"
}
},
"integration_discovery_confirm": {
diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json
index 6707cb7ddb3..9e55ade0a63 100644
--- a/homeassistant/components/zabbix/manifest.json
+++ b/homeassistant/components/zabbix/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["zabbix_utils"],
"quality_scale": "legacy",
- "requirements": ["zabbix-utils==2.0.2"]
+ "requirements": ["zabbix-utils==2.0.3"]
}
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index fe190e78956..f3da07eeeb5 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.147.0"]
+ "requirements": ["zeroconf==0.148.0"]
}
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index e446f32cf08..c3406181ff8 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -134,7 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
device_registry = dr.async_get(hass)
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
- async with radio_mgr.connect_zigpy_app() as app:
+ async with radio_mgr.create_zigpy_app(connect=False) as app:
for dev in app.devices.values():
dev_entry = device_registry.async_get_device(
identifiers={(DOMAIN, str(dev.ieee))},
diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py
index 60960a3e9fc..9dbd00273b6 100644
--- a/homeassistant/components/zha/api.py
+++ b/homeassistant/components/zha/api.py
@@ -56,7 +56,7 @@ async def async_get_last_network_settings(
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
- async with radio_mgr.connect_zigpy_app() as app:
+ async with radio_mgr.create_zigpy_app(connect=False) as app:
try:
settings = max(app.backups, key=lambda b: b.backup_time)
except ValueError:
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index b98e53f98d8..bece865bef2 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+from abc import abstractmethod
import collections
from contextlib import suppress
+from enum import StrEnum
import json
from typing import Any
@@ -13,11 +15,15 @@ import voluptuous as vol
from zha.application.const import RadioType
import zigpy.backups
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
+from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetworkSettings
from homeassistant.components import onboarding, usb
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
+from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
+ ZigbeeFlowStrategy,
+)
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
from homeassistant.config_entries import (
SOURCE_IGNORE,
@@ -32,6 +38,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
+from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
@@ -40,6 +47,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util import dt as dt_util
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
+from .helpers import get_zha_gateway
from .radio_manager import (
DEVICE_SCHEMA,
HARDWARE_DISCOVERY_SCHEMA,
@@ -49,12 +57,22 @@ from .radio_manager import (
)
CONF_MANUAL_PATH = "Enter Manually"
-SUPPORTED_PORT_SETTINGS = (
- CONF_BAUDRATE,
- CONF_FLOW_CONTROL,
-)
DECONZ_DOMAIN = "deconz"
+# The ZHA config flow takes different branches depending on if you are migrating to a
+# new adapter via discovery or setting it up from scratch
+
+# For the fast path, we automatically migrate everything and restore the most recent backup
+MIGRATION_STRATEGY_RECOMMENDED = "migration_strategy_recommended"
+MIGRATION_STRATEGY_ADVANCED = "migration_strategy_advanced"
+
+# Similarly, setup follows the same approach: we create a new network
+SETUP_STRATEGY_RECOMMENDED = "setup_strategy_recommended"
+SETUP_STRATEGY_ADVANCED = "setup_strategy_advanced"
+
+# For the advanced paths, we allow users to pick how to form a network: form a brand new
+# network, use the settings currently on the stick, restore from a database backup, or
+# restore from a JSON backup
FORMATION_STRATEGY = "formation_strategy"
FORMATION_FORM_NEW_NETWORK = "form_new_network"
FORMATION_FORM_INITIAL_NETWORK = "form_initial_network"
@@ -65,9 +83,6 @@ FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup"
CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee"
-OPTIONS_INTENT_MIGRATE = "intent_migrate"
-OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
-
UPLOADED_BACKUP_FILE = "uploaded_backup_file"
REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/"
@@ -85,6 +100,13 @@ ZEROCONF_PROPERTIES_SCHEMA = vol.Schema(
)
+class OptionsMigrationIntent(StrEnum):
+ """Zigbee options flow intents."""
+
+ MIGRATE = "intent_migrate"
+ RECONFIGURE = "intent_reconfigure"
+
+
def _format_backup_choice(
backup: zigpy.backups.NetworkBackup, *, pan_ids: bool = True
) -> str:
@@ -149,6 +171,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
class BaseZhaFlow(ConfigEntryBaseFlow):
"""Mixin for common ZHA flow steps and forms."""
+ _flow_strategy: ZigbeeFlowStrategy | None = None
_hass: HomeAssistant
_title: str
@@ -170,24 +193,25 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._hass = hass
self._radio_mgr.hass = hass
- async def _async_create_radio_entry(self) -> ConfigFlowResult:
- """Create a config entry with the current flow state."""
+ def _get_config_entry_data(self) -> dict[str, Any]:
+ """Extract ZHA config entry data from the radio manager."""
assert self._radio_mgr.radio_type is not None
assert self._radio_mgr.device_path is not None
assert self._radio_mgr.device_settings is not None
- device_settings = self._radio_mgr.device_settings.copy()
- device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job(
- usb.get_serial_by_id, self._radio_mgr.device_path
- )
+ return {
+ CONF_DEVICE: DEVICE_SCHEMA(
+ {
+ **self._radio_mgr.device_settings,
+ CONF_DEVICE_PATH: self._radio_mgr.device_path,
+ }
+ ),
+ CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
+ }
- return self.async_create_entry(
- title=self._title,
- data={
- CONF_DEVICE: DEVICE_SCHEMA(device_settings),
- CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
- },
- )
+ @abstractmethod
+ async def _async_create_radio_entry(self) -> ConfigFlowResult:
+ """Create a config entry with the current flow state."""
async def async_step_choose_serial_port(
self, user_input: dict[str, Any] | None = None
@@ -288,43 +312,46 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
if user_input is not None:
self._title = user_input[CONF_DEVICE_PATH]
self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH]
- self._radio_mgr.device_settings = user_input.copy()
+ self._radio_mgr.device_settings = DEVICE_SCHEMA(
+ {
+ CONF_DEVICE_PATH: self._radio_mgr.device_path,
+ CONF_BAUDRATE: user_input[CONF_BAUDRATE],
+ # `None` shows up as the empty string in the frontend
+ CONF_FLOW_CONTROL: (
+ user_input[CONF_FLOW_CONTROL]
+ if user_input[CONF_FLOW_CONTROL] != "none"
+ else None
+ ),
+ }
+ )
- if await self._radio_mgr.radio_type.controller.probe(user_input):
+ if await self._radio_mgr.radio_type.controller.probe(
+ self._radio_mgr.device_settings
+ ):
return await self.async_step_verify_radio()
errors["base"] = "cannot_connect"
- schema = {
- vol.Required(
- CONF_DEVICE_PATH, default=self._radio_mgr.device_path or vol.UNDEFINED
- ): str
- }
-
- source = self.context.get("source")
- for (
- param,
- value,
- ) in DEVICE_SCHEMA.schema.items():
- if param not in SUPPORTED_PORT_SETTINGS:
- continue
-
- if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE:
- value = 115200
- param = vol.Required(CONF_BAUDRATE, default=value)
- elif (
- self._radio_mgr.device_settings is not None
- and param in self._radio_mgr.device_settings
- ):
- param = vol.Required(
- str(param), default=self._radio_mgr.device_settings[param]
- )
-
- schema[param] = value
+ device_settings = self._radio_mgr.device_settings or {}
return self.async_show_form(
step_id="manual_port_config",
- data_schema=vol.Schema(schema),
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_DEVICE_PATH,
+ default=self._radio_mgr.device_path or vol.UNDEFINED,
+ ): str,
+ vol.Required(
+ CONF_BAUDRATE,
+ default=device_settings.get(CONF_BAUDRATE) or 115200,
+ ): int,
+ vol.Required(
+ CONF_FLOW_CONTROL,
+ default=device_settings.get(CONF_FLOW_CONTROL) or "none",
+ ): vol.In(["hardware", "software", "none"]),
+ }
+ ),
errors=errors,
)
@@ -333,10 +360,15 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Add a warning step to dissuade the use of deprecated radios."""
assert self._radio_mgr.radio_type is not None
+ await self._radio_mgr.async_read_backups_from_database()
# Skip this step if we are using a recommended radio
if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS:
- return await self.async_step_choose_formation_strategy()
+ # ZHA disables the single instance check and will decide at runtime if we
+ # are migrating or setting up from scratch
+ if self.hass.config_entries.async_entries(DOMAIN, include_ignore=False):
+ return await self.async_step_choose_migration_strategy()
+ return await self.async_step_choose_setup_strategy()
return self.async_show_form(
step_id="verify_radio",
@@ -348,6 +380,109 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
},
)
+ async def async_step_choose_setup_strategy(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Choose how to set up the integration from scratch."""
+ if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED:
+ # Fast path: automatically form a new network
+ return await self.async_step_setup_strategy_recommended()
+ if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED:
+ # Advanced path: let the user choose
+ return await self.async_step_setup_strategy_advanced()
+
+ # Allow onboarding for new users to just create a new network automatically
+ if (
+ not onboarding.async_is_onboarded(self.hass)
+ and not self.hass.config_entries.async_entries(DOMAIN, include_ignore=False)
+ and not self._radio_mgr.backups
+ ):
+ return await self.async_step_setup_strategy_recommended()
+
+ return self.async_show_menu(
+ step_id="choose_setup_strategy",
+ menu_options=[
+ SETUP_STRATEGY_RECOMMENDED,
+ SETUP_STRATEGY_ADVANCED,
+ ],
+ )
+
+ async def async_step_setup_strategy_recommended(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Recommended setup strategy: form a brand-new network."""
+ return await self.async_step_form_new_network()
+
+ async def async_step_setup_strategy_advanced(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Advanced setup strategy: let the user choose."""
+ return await self.async_step_choose_formation_strategy()
+
+ async def async_step_choose_migration_strategy(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Choose how to deal with the current radio's settings during migration."""
+ if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED:
+ # Fast path: automatically migrate everything
+ return await self.async_step_migration_strategy_recommended()
+ if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED:
+ # Advanced path: let the user choose
+ return await self.async_step_migration_strategy_advanced()
+ return self.async_show_menu(
+ step_id="choose_migration_strategy",
+ menu_options=[
+ MIGRATION_STRATEGY_RECOMMENDED,
+ MIGRATION_STRATEGY_ADVANCED,
+ ],
+ )
+
+ async def async_step_migration_strategy_recommended(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Recommended migration strategy: automatically migrate everything."""
+
+ # Assume the most recent backup is the correct one
+ self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
+ return await self.async_step_maybe_reset_old_radio()
+
+ async def async_step_maybe_reset_old_radio(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Erase the old radio's network settings before migration."""
+
+ # Like in the options flow, pull the correct settings from the config entry
+ config_entries = self.hass.config_entries.async_entries(
+ DOMAIN, include_ignore=False
+ )
+
+ if config_entries:
+ assert len(config_entries) == 1
+ config_entry = config_entries[0]
+
+ # Unload ZHA before connecting to the old adapter
+ with suppress(OperationNotAllowed):
+ await self.hass.config_entries.async_unload(config_entry.entry_id)
+
+ # Create a radio manager to connect to the old stick to reset it
+ temp_radio_mgr = ZhaRadioManager()
+ temp_radio_mgr.hass = self.hass
+ temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][
+ CONF_DEVICE_PATH
+ ]
+ temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
+ temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
+
+ await temp_radio_mgr.async_reset_adapter()
+
+ return await self.async_step_maybe_confirm_ezsp_restore()
+
+ async def async_step_migration_strategy_advanced(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Advanced migration strategy: let the user choose."""
+ return await self.async_step_choose_formation_strategy()
+
async def async_step_choose_formation_strategy(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -434,7 +569,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
except ValueError:
errors["base"] = "invalid_backup_json"
else:
- return await self.async_step_maybe_confirm_ezsp_restore()
+ return await self.async_step_maybe_reset_old_radio()
return self.async_show_form(
step_id="upload_manual_backup",
@@ -474,7 +609,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP])
self._radio_mgr.chosen_backup = self._radio_mgr.backups[index]
- return await self.async_step_maybe_confirm_ezsp_restore()
+ return await self.async_step_maybe_reset_old_radio()
return self.async_show_form(
step_id="choose_automatic_backup",
@@ -491,16 +626,37 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm restore for EZSP radios that require permanent IEEE writes."""
- call_step_2 = await self._radio_mgr.async_restore_backup_step_1()
- if not call_step_2:
- return await self._async_create_radio_entry()
-
if user_input is not None:
- await self._radio_mgr.async_restore_backup_step_2(
- user_input[OVERWRITE_COORDINATOR_IEEE]
+ if user_input[OVERWRITE_COORDINATOR_IEEE]:
+ # On confirmation, overwrite destructively
+ try:
+ await self._radio_mgr.restore_backup(overwrite_ieee=True)
+ except CannotWriteNetworkSettings as exc:
+ return self.async_abort(
+ reason="cannot_restore_backup",
+ description_placeholders={"error": str(exc)},
+ )
+
+ return await self._async_create_radio_entry()
+
+ # On rejection, explain why we can't restore
+ return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm")
+
+ # On first attempt, just try to restore nondestructively
+ try:
+ await self._radio_mgr.restore_backup()
+ except DestructiveWriteNetworkSettings:
+ # Restore cannot happen automatically, we need to ask for permission
+ pass
+ except CannotWriteNetworkSettings as exc:
+ return self.async_abort(
+ reason="cannot_restore_backup",
+ description_placeholders={"error": str(exc)},
)
+ else:
return await self._async_create_radio_entry()
+ # If it fails, show the form
return self.async_show_form(
step_id="maybe_confirm_ezsp_restore",
data_schema=vol.Schema(
@@ -520,13 +676,8 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
"""Set the flow's unique ID and update the device path in an ignored flow."""
current_entry = await self.async_set_unique_id(unique_id)
- if not current_entry:
- return
-
- if current_entry.source != SOURCE_IGNORE:
- self._abort_if_unique_id_configured()
- else:
- # Only update the current entry if it is an ignored discovery
+ # Only update the current entry if it is an ignored discovery
+ if current_entry and current_entry.source == SOURCE_IGNORE:
self._abort_if_unique_id_configured(
updates={
CONF_DEVICE: {
@@ -548,24 +699,54 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a ZHA config flow start."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
return await self.async_step_choose_serial_port(user_input)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a discovery."""
+
self._set_confirm_only()
- # Don't permit discovery if ZHA is already set up
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
+ zha_config_entries = self.hass.config_entries.async_entries(
+ DOMAIN, include_ignore=False
+ )
+
+ if self._radio_mgr.device_path is not None:
+ # Ensure the radio manager device path is unique and will match ZHA's
+ try:
+ self._radio_mgr.device_path = await self.hass.async_add_executor_job(
+ usb.get_serial_by_id, self._radio_mgr.device_path
+ )
+ except OSError as error:
+ raise AbortFlow(
+ reason="cannot_resolve_path",
+ description_placeholders={"path": self._radio_mgr.device_path},
+ ) from error
+
+ # mDNS discovery can advertise the same adapter on multiple IPs or via a
+ # hostname, which should be considered a duplicate
+ current_device_paths = {self._radio_mgr.device_path}
+
+ if self.source == SOURCE_ZEROCONF:
+ discovery_info = self.init_data
+ current_device_paths |= {
+ f"socket://{ip}:{discovery_info.port}"
+ for ip in discovery_info.ip_addresses
+ }
+
+ for entry in zha_config_entries:
+ path = entry.data.get(CONF_DEVICE, {}).get(CONF_DEVICE_PATH)
+
+ # Abort discovery if the device path is already configured
+ if path is not None and path in current_device_paths:
+ return self.async_abort(reason="single_instance_allowed")
# Without confirmation, discovery can automatically progress into parts of the
# config flow logic that interacts with hardware.
- if user_input is not None or not onboarding.async_is_onboarded(self.hass):
+ if user_input is not None or (
+ not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
+ ):
# Probe the radio type if we don't have one yet
if self._radio_mgr.radio_type is None:
probe_result = await self._radio_mgr.detect_radio_type()
@@ -686,11 +867,13 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
self._title = title
self._radio_mgr.device_path = device_path
self._radio_mgr.radio_type = radio_type
- self._radio_mgr.device_settings = {
- CONF_DEVICE_PATH: device_path,
- CONF_BAUDRATE: 115200,
- CONF_FLOW_CONTROL: None,
- }
+ self._radio_mgr.device_settings = DEVICE_SCHEMA(
+ {
+ CONF_DEVICE_PATH: device_path,
+ CONF_BAUDRATE: 115200,
+ CONF_FLOW_CONTROL: None,
+ }
+ )
return await self.async_step_confirm()
@@ -707,6 +890,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"])
device_settings = discovery_data["port"]
device_path = device_settings[CONF_DEVICE_PATH]
+ self._flow_strategy = discovery_data.get("flow_strategy")
await self._set_unique_id_and_update_ignored_flow(
unique_id=f"{name}_{radio_type.name}_{device_path}",
@@ -721,10 +905,38 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
+ async def _async_create_radio_entry(self) -> ConfigFlowResult:
+ """Create a config entry with the current flow state."""
+
+ # ZHA is still single instance only, even though we use discovery to allow for
+ # migrating to a new radio
+ zha_config_entries = self.hass.config_entries.async_entries(
+ DOMAIN, include_ignore=False
+ )
+ data = self._get_config_entry_data()
+
+ if len(zha_config_entries) == 1:
+ return self.async_update_reload_and_abort(
+ entry=zha_config_entries[0],
+ title=self._title,
+ data=data,
+ reload_even_if_entry_is_unchanged=True,
+ reason="reconfigure_successful",
+ )
+ if not zha_config_entries:
+ return self.async_create_entry(
+ title=self._title,
+ data=data,
+ )
+ # This should never be reached
+ return self.async_abort(reason="single_instance_allowed")
+
class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
"""Handle an options flow."""
+ _migration_intent: OptionsMigrationIntent
+
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
super().__init__()
@@ -738,8 +950,20 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
) -> ConfigFlowResult:
"""Launch the options flow."""
if user_input is not None:
- # OperationNotAllowed: ZHA is not running
+ # Perform a backup first
+ try:
+ zha_gateway = get_zha_gateway(self.hass)
+ except ValueError:
+ pass
+ else:
+ # The backup itself will be stored in `zigbee.db`, which the radio
+ # manager will read when the class is initialized
+ application_controller = zha_gateway.application_controller
+ await application_controller.backups.create_backup(load_devices=True)
+
+ # Then unload the integration
with suppress(OperationNotAllowed):
+ # OperationNotAllowed: ZHA is not running
await self.hass.config_entries.async_unload(self.config_entry.entry_id)
return await self.async_step_prompt_migrate_or_reconfigure()
@@ -754,8 +978,8 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
return self.async_show_menu(
step_id="prompt_migrate_or_reconfigure",
menu_options=[
- OPTIONS_INTENT_RECONFIGURE,
- OPTIONS_INTENT_MIGRATE,
+ OptionsMigrationIntent.RECONFIGURE,
+ OptionsMigrationIntent.MIGRATE,
],
)
@@ -763,53 +987,41 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Virtual step for when the user is reconfiguring the integration."""
+ self._migration_intent = OptionsMigrationIntent.RECONFIGURE
return await self.async_step_choose_serial_port()
async def async_step_intent_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the user wants to reset their current radio."""
+ self._migration_intent = OptionsMigrationIntent.MIGRATE
+ return await self.async_step_choose_serial_port()
- if user_input is not None:
- await self._radio_mgr.async_reset_adapter()
-
- return await self.async_step_instruct_unplug()
-
- return self.async_show_form(step_id="intent_migrate")
-
- async def async_step_instruct_unplug(
+ async def async_step_maybe_reset_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Instruct the user to unplug the current radio, if possible."""
+ """Erase the old radio's network settings before migration."""
- if user_input is not None:
- # Now that the old radio is gone, we can scan for serial ports again
- return await self.async_step_choose_serial_port()
+ # If we are reconfiguring, the old radio will not be available
+ if self._migration_intent is OptionsMigrationIntent.RECONFIGURE:
+ return await self.async_step_maybe_confirm_ezsp_restore()
- return self.async_show_form(step_id="instruct_unplug")
+ return await super().async_step_maybe_reset_old_radio(user_input)
async def _async_create_radio_entry(self):
"""Re-implementation of the base flow's final step to update the config."""
- device_settings = self._radio_mgr.device_settings.copy()
- device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job(
- usb.get_serial_by_id, self._radio_mgr.device_path
- )
# Avoid creating both `.options` and `.data` by directly writing `data` here
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
- data={
- CONF_DEVICE: device_settings,
- CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
- },
+ data=self._get_config_entry_data(),
options=self.config_entry.options,
)
# Reload ZHA after we finish
await self.hass.config_entries.async_setup(self.config_entry.entry_id)
- # Intentionally do not set `data` to avoid creating `options`, we set it above
- return self.async_create_entry(title=self._title, data={})
+ return self.async_abort(reason="reconfigure_successful")
def async_remove(self):
"""Maybe reload ZHA if the flow is aborted."""
diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py
index d058f37ff6b..36b9a001506 100644
--- a/homeassistant/components/zha/cover.py
+++ b/homeassistant/components/zha/cover.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from collections.abc import Mapping
import functools
import logging
from typing import Any
@@ -90,15 +89,6 @@ class ZhaCover(ZHAEntity, CoverEntity):
self._attr_supported_features = features
- @property
- def extra_state_attributes(self) -> Mapping[str, Any] | None:
- """Return entity specific state attributes."""
- state = self.entity_data.entity.state
- return {
- "target_lift_position": state.get("target_lift_position"),
- "target_tilt_position": state.get("target_tilt_position"),
- }
-
@property
def is_closed(self) -> bool | None:
"""Return True if the cover is closed."""
@@ -185,8 +175,4 @@ class ZhaCover(ZHAEntity, CoverEntity):
return
# Same as `light`, some entity state is not derived from ZCL attributes
- self.entity_data.entity.restore_external_state_attributes(
- state=state.state,
- target_lift_position=state.attributes.get("target_lift_position"),
- target_tilt_position=state.attributes.get("target_tilt_position"),
- )
+ self.entity_data.entity.restore_external_state_attributes(state=state.state)
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 9f5e6a91905..307b287d8f5 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["zha==0.0.69"],
+ "requirements": ["zha==0.0.73"],
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py
index 6a5d39bc3db..1a2da153902 100644
--- a/homeassistant/components/zha/radio_manager.py
+++ b/homeassistant/components/zha/radio_manager.py
@@ -1,4 +1,4 @@
-"""Config flow for ZHA."""
+"""ZHA radio manager."""
from __future__ import annotations
@@ -28,7 +28,11 @@ from zigpy.exceptions import NetworkNotFormed
from homeassistant import config_entries
from homeassistant.components import usb
+from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
+ ZigbeeFlowStrategy,
+)
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import repairs
@@ -40,22 +44,17 @@ from .const import (
)
from .helpers import get_zha_data
-# Only the common radio types will be autoprobed, ordered by new device popularity.
-# XBee takes too long to probe since it scans through all possible bauds and likely has
-# very few users to begin with.
-AUTOPROBE_RADIOS = (
- RadioType.ezsp,
- RadioType.znp,
- RadioType.deconz,
- RadioType.zigate,
-)
-
RECOMMENDED_RADIOS = (
RadioType.ezsp,
RadioType.znp,
RadioType.deconz,
)
+# Only the common radio types will be autoprobed, ordered by new device popularity.
+# XBee takes too long to probe since it scans through all possible bauds and likely has
+# very few users to begin with.
+AUTOPROBE_RADIOS = RECOMMENDED_RADIOS
+
CONNECT_DELAY_S = 1.0
RETRY_DELAY_S = 1.0
@@ -78,6 +77,7 @@ HARDWARE_DISCOVERY_SCHEMA = vol.Schema(
vol.Required("name"): str,
vol.Required("port"): DEVICE_SCHEMA,
vol.Required("radio_type"): str,
+ vol.Optional("flow_strategy"): vol.All(str, vol.Coerce(ZigbeeFlowStrategy)),
}
)
@@ -158,22 +158,38 @@ class ZhaRadioManager:
return mgr
+ @property
+ def zigpy_database_path(self) -> str:
+ """Path to `zigbee.db`."""
+ config = get_zha_data(self.hass).yaml_config
+
+ return config.get(
+ CONF_DATABASE,
+ self.hass.config.path(DEFAULT_DATABASE_NAME),
+ )
+
@contextlib.asynccontextmanager
- async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]:
+ async def create_zigpy_app(
+ self, *, connect: bool = True
+ ) -> AsyncIterator[ControllerApplication]:
"""Connect to the radio with the current config and then clean up."""
assert self.radio_type is not None
config = get_zha_data(self.hass).yaml_config
app_config = config.get(CONF_ZIGPY, {}).copy()
- database_path = config.get(
- CONF_DATABASE,
- self.hass.config.path(DEFAULT_DATABASE_NAME),
- )
+ database_path: str | None = self.zigpy_database_path
# Don't create `zigbee.db` if it doesn't already exist
- if not await self.hass.async_add_executor_job(os.path.exists, database_path):
- database_path = None
+ try:
+ if database_path is not None and not await self.hass.async_add_executor_job(
+ os.path.exists, database_path
+ ):
+ database_path = None
+ except OSError as error:
+ raise HomeAssistantError(
+ f"Could not read the ZHA database {database_path}: {error}"
+ ) from error
app_config[CONF_DATABASE] = database_path
app_config[CONF_DEVICE] = self.device_settings
@@ -185,22 +201,45 @@ class ZhaRadioManager:
)
try:
+ if connect:
+ try:
+ await app.connect()
+ except OSError as error:
+ raise HomeAssistantError(
+ f"Failed to connect to Zigbee adapter: {error}"
+ ) from error
+
yield app
finally:
await app.shutdown()
await asyncio.sleep(CONNECT_DELAY_S)
async def restore_backup(
- self, backup: zigpy.backups.NetworkBackup, **kwargs: Any
+ self,
+ backup: zigpy.backups.NetworkBackup | None = None,
+ *,
+ overwrite_ieee: bool = False,
+ **kwargs: Any,
) -> None:
"""Restore the provided network backup, passing through kwargs."""
+ if backup is None:
+ backup = self.chosen_backup
+
+ assert backup is not None
+
if self.current_settings is not None and self.current_settings.supersedes(
- self.chosen_backup
+ backup
):
return
- async with self.connect_zigpy_app() as app:
- await app.connect()
+ if overwrite_ieee:
+ backup = _allow_overwrite_ezsp_ieee(backup)
+
+ async with self.create_zigpy_app() as app:
+ await app.can_write_network_settings(
+ network_info=backup.network_info,
+ node_info=backup.node_info,
+ )
await app.backups.restore_backup(backup, **kwargs)
@staticmethod
@@ -242,15 +281,27 @@ class ZhaRadioManager:
return ProbeResult.PROBING_FAILED
+ async def _async_read_backups_from_database(
+ self,
+ ) -> list[zigpy.backups.NetworkBackup]:
+ """Read the list of backups from the database, internal."""
+ async with self.create_zigpy_app(connect=False) as app:
+ backups = app.backups.backups.copy()
+ backups.sort(reverse=True, key=lambda b: b.backup_time)
+
+ return backups
+
+ async def async_read_backups_from_database(self) -> None:
+ """Read the list of backups from the database."""
+ self.backups = await self._async_read_backups_from_database()
+
async def async_load_network_settings(
self, *, create_backup: bool = False
) -> zigpy.backups.NetworkBackup | None:
"""Connect to the radio and load its current network settings."""
backup = None
- async with self.connect_zigpy_app() as app:
- await app.connect()
-
+ async with self.create_zigpy_app() as app:
# Check if the stick has any settings and load them
try:
await app.load_network_info()
@@ -273,66 +324,20 @@ class ZhaRadioManager:
async def async_form_network(self) -> None:
"""Form a brand-new network."""
- async with self.connect_zigpy_app() as app:
- await app.connect()
+
+ # When forming a new network, we delete the ZHA database to prevent old devices
+ # from appearing in an unusable state
+ with suppress(OSError):
+ await self.hass.async_add_executor_job(os.remove, self.zigpy_database_path)
+
+ async with self.create_zigpy_app() as app:
await app.form_network()
async def async_reset_adapter(self) -> None:
"""Reset the current adapter."""
- async with self.connect_zigpy_app() as app:
- await app.connect()
+ async with self.create_zigpy_app() as app:
await app.reset_network_info()
- async def async_restore_backup_step_1(self) -> bool:
- """Prepare restoring backup.
-
- Returns True if async_restore_backup_step_2 should be called.
- """
- assert self.chosen_backup is not None
-
- if self.radio_type != RadioType.ezsp:
- await self.restore_backup(self.chosen_backup)
- return False
-
- # We have no way to partially load network settings if no network is formed
- if self.current_settings is None:
- # Since we are going to be restoring the backup anyways, write it to the
- # radio without overwriting the IEEE but don't take a backup with these
- # temporary settings
- temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup)
- await self.restore_backup(temp_backup, create_new=False)
- await self.async_load_network_settings()
-
- assert self.current_settings is not None
-
- metadata = self.current_settings.network_info.metadata["ezsp"]
-
- if (
- self.current_settings.node_info.ieee == self.chosen_backup.node_info.ieee
- or metadata["can_rewrite_custom_eui64"]
- or not metadata["can_burn_userdata_custom_eui64"]
- ):
- # No point in prompting the user if the backup doesn't have a new IEEE
- # address or if there is no way to overwrite the IEEE address a second time
- await self.restore_backup(self.chosen_backup)
-
- return False
-
- return True
-
- async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None:
- """Restore backup and optionally overwrite IEEE."""
- assert self.chosen_backup is not None
-
- backup = self.chosen_backup
-
- if overwrite_ieee:
- backup = _allow_overwrite_ezsp_ieee(backup)
-
- # If the user declined to overwrite the IEEE *and* we wrote the backup to
- # their empty radio above, restoring it again would be redundant.
- await self.restore_backup(backup)
-
class ZhaMultiPANMigrationHelper:
"""Helper class for automatic migration when upgrading the firmware of a radio.
@@ -442,9 +447,7 @@ class ZhaMultiPANMigrationHelper:
# Restore the backup, permanently overwriting the device IEEE address
for retry in range(MIGRATION_RETRIES):
try:
- if await self._radio_mgr.async_restore_backup_step_1():
- await self._radio_mgr.async_restore_backup_step_2(True)
-
+ await self._radio_mgr.restore_backup(overwrite_ieee=True)
break
except OSError as err:
if retry >= MIGRATION_RETRIES - 1:
diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py
index ef38ebc3d47..609dda5100b 100644
--- a/homeassistant/components/zha/repairs/network_settings_inconsistent.py
+++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py
@@ -136,7 +136,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow):
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Step to use the new settings found on the radio."""
- async with self._radio_mgr.connect_zigpy_app() as app:
+ async with self._radio_mgr.create_zigpy_app(connect=False) as app:
app.backups.add_backup(self._new_state)
await self.hass.config_entries.async_reload(self._entry_id)
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index 1c9454ec0a0..06ab143b6bd 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -4,7 +4,7 @@
"step": {
"choose_serial_port": {
"title": "Select a serial port",
- "description": "Select the serial port for your Zigbee radio",
+ "description": "Select the serial port for your Zigbee adapter",
"data": {
"path": "Serial device path"
}
@@ -16,34 +16,74 @@
"description": "Do you want to set up {name}?"
},
"manual_pick_radio_type": {
- "title": "Select a radio type",
- "description": "Pick your Zigbee radio type",
+ "title": "Select an adapter type",
+ "description": "Pick your Zigbee adapter type",
"data": {
- "radio_type": "Radio type"
+ "radio_type": "Adapter type"
}
},
"manual_port_config": {
"title": "Serial port settings",
- "description": "Enter the serial port settings",
+ "description": "ZHA was not able to automatically detect serial port settings for your adapter. This usually is an issue with the firmware or permissions.\n\nIf you are using firmware with nonstandard settings, enter the serial port settings",
"data": {
"path": "Serial device path",
- "baudrate": "Port speed",
- "flow_control": "Data flow control"
+ "baudrate": "Serial port speed",
+ "flow_control": "Serial port flow control"
+ },
+ "data_description": {
+ "path": "Path to the serial port or `socket://` TCP address",
+ "baudrate": "Baudrate to use when communicating with the serial port, usually 115200 or 460800",
+ "flow_control": "Check your adapter's documentation for the correct option, usually `None` or `Hardware`"
}
},
"verify_radio": {
- "title": "Radio is not recommended",
- "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
+ "title": "Adapter is not recommended",
+ "description": "The adapter you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
+ },
+ "choose_setup_strategy": {
+ "title": "Set up Zigbee",
+ "description": "Choose how you want to set up Zigbee. Automatic setup is recommended unless you are restoring your network from a backup or setting up an adapter with nonstandard settings.",
+ "menu_options": {
+ "setup_strategy_recommended": "Set up automatically (recommended)",
+ "setup_strategy_advanced": "Advanced setup"
+ },
+ "menu_option_descriptions": {
+ "setup_strategy_recommended": "This is the quickest option to create a new network and get started.",
+ "setup_strategy_advanced": "This will let you restore from a backup."
+ }
+ },
+ "choose_migration_strategy": {
+ "title": "Migrate to a new adapter",
+ "description": "Choose how you want to migrate your Zigbee network backup from your old adapter to a new one.",
+ "menu_options": {
+ "migration_strategy_recommended": "Migrate automatically (recommended)",
+ "migration_strategy_advanced": "Advanced migration"
+ },
+ "menu_option_descriptions": {
+ "migration_strategy_recommended": "This is the quickest option to migrate to a new adapter.",
+ "migration_strategy_advanced": "This will let you restore a specific network backup or upload your own."
+ }
+ },
+ "maybe_reset_old_radio": {
+ "title": "Resetting old adapter",
+ "description": "A backup was created earlier and your old adapter is being reset as part of the migration."
},
"choose_formation_strategy": {
"title": "Network formation",
- "description": "Choose the network settings for your radio.",
+ "description": "Choose the network settings for your adapter.",
"menu_options": {
"form_new_network": "Erase network settings and create a new network",
"form_initial_network": "Create a network",
- "reuse_settings": "Keep radio network settings",
+ "reuse_settings": "Keep adapter network settings",
"choose_automatic_backup": "Restore an automatic backup",
"upload_manual_backup": "Upload a manual backup"
+ },
+ "menu_option_descriptions": {
+ "form_new_network": "This will create a new Zigbee network.",
+ "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
+ "reuse_settings": "This will let ZHA import the settings from a stick that was used with other software, migrating some of the network automatically.",
+ "choose_automatic_backup": "This will let you change your adapter's network settings back to a previous state, in case you have changed them.",
+ "upload_manual_backup": "This will let you upload a backup JSON file from ZHA or the Zigbee2MQTT `coordinator_backup.json` file."
}
},
"choose_automatic_backup": {
@@ -61,10 +101,10 @@
}
},
"maybe_confirm_ezsp_restore": {
- "title": "Overwrite radio IEEE address",
- "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.",
+ "title": "Overwrite adapter IEEE address",
+ "description": "Your backup has a different IEEE address than your adapter. For your network to function properly, the IEEE address of your adapter should also be changed.\n\nThis is a permanent operation.",
"data": {
- "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address"
+ "overwrite_coordinator_ieee": "Permanently replace the adapter IEEE address"
}
}
},
@@ -76,8 +116,12 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_zha_device": "This device is not a ZHA device",
"usb_probe_failed": "Failed to probe the USB device",
+ "cannot_resolve_path": "Could not resolve device path: {path}",
"wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
- "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA"
+ "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA",
+ "cannot_restore_backup": "The adapter you are restoring to does not properly support backup restoration. Please upgrade the firmware.\n\nError: {error}",
+ "cannot_restore_backup_no_ieee_confirm": "The adapter you are restoring to has outdated firmware and cannot write the adapter IEEE address multiple times. Please upgrade the firmware or confirm permanent overwrite in the previous step.",
+ "reconfigure_successful": "ZHA has successfully migrated from your old adapter to the new one. Give your Zigbee network a few minutes to stabilize.\n\nIf you no longer need the old adapter, you can now unplug it."
}
},
"options": {
@@ -85,23 +129,23 @@
"step": {
"init": {
"title": "Reconfigure ZHA",
- "description": "ZHA will be stopped. Do you wish to continue?"
+ "description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?"
},
"prompt_migrate_or_reconfigure": {
"title": "Migrate or re-configure",
- "description": "Are you migrating to a new radio or re-configuring the current radio?",
+ "description": "Are you migrating to a new adapter or re-configuring the current adapter?",
"menu_options": {
- "intent_migrate": "Migrate to a new radio",
- "intent_reconfigure": "Re-configure the current radio"
+ "intent_migrate": "Migrate to a new adapter",
+ "intent_reconfigure": "Re-configure the current adapter"
+ },
+ "menu_option_descriptions": {
+ "intent_migrate": "This will help you migrate your Zigbee network from your old adapter to a new one.",
+ "intent_reconfigure": "This will let you change the serial port for your current Zigbee adapter."
}
},
"intent_migrate": {
"title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]",
- "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
- },
- "instruct_unplug": {
- "title": "Unplug your old radio",
- "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio."
+ "description": "Before plugging in your new adapter, your old adapter needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
},
"choose_serial_port": {
"title": "[%key:component::zha::config::step::choose_serial_port::title%]",
@@ -130,6 +174,18 @@
"title": "[%key:component::zha::config::step::verify_radio::title%]",
"description": "[%key:component::zha::config::step::verify_radio::description%]"
},
+ "choose_migration_strategy": {
+ "title": "[%key:component::zha::config::step::choose_migration_strategy::title%]",
+ "description": "[%key:component::zha::config::step::choose_migration_strategy::description%]",
+ "menu_options": {
+ "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_recommended%]",
+ "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_advanced%]"
+ },
+ "menu_option_descriptions": {
+ "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_recommended%]",
+ "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_advanced%]"
+ }
+ },
"choose_formation_strategy": {
"title": "[%key:component::zha::config::step::choose_formation_strategy::title%]",
"description": "[%key:component::zha::config::step::choose_formation_strategy::description%]",
@@ -139,6 +195,13 @@
"reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]",
"choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]",
"upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]"
+ },
+ "menu_option_descriptions": {
+ "form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
+ "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
+ "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::reuse_settings%]",
+ "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::choose_automatic_backup%]",
+ "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::upload_manual_backup%]"
}
},
"choose_automatic_backup": {
@@ -168,10 +231,13 @@
"invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]",
"usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]",
- "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]"
+ "cannot_resolve_path": "[%key:component::zha::config::abort::cannot_resolve_path%]",
+ "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]",
+ "cannot_restore_backup": "[%key:component::zha::config::abort::cannot_restore_backup%]",
+ "cannot_restore_backup_no_ieee_confirm": "[%key:component::zha::config::abort::cannot_restore_backup_no_ieee_confirm%]",
+ "reconfigure_successful": "[%key:component::zha::config::abort::reconfigure_successful%]"
}
},
"config_panel": {
@@ -515,12 +581,12 @@
},
"issues": {
"wrong_silabs_firmware_installed_nabucasa": {
- "title": "Zigbee radio with multiprotocol firmware detected",
- "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in Step 2 (and Step 2 only) to 'Flash the Silicon Labs radio Zigbee firmware'.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration."
+ "title": "Zigbee adapter with multiprotocol firmware detected",
+ "description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install the Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions."
},
"wrong_silabs_firmware_installed_other": {
"title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]",
- "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this."
+ "description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee adapter manufacturer's instructions for how to do this."
},
"inconsistent_network_settings": {
"title": "Zigbee network settings have changed",
@@ -528,10 +594,14 @@
"step": {
"init": {
"title": "[%key:component::zha::issues::inconsistent_network_settings::title%]",
- "description": "Your Zigbee radio's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.",
+ "description": "Your Zigbee adapter's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.",
"menu_options": {
"use_new_settings": "Keep the new settings",
"restore_old_settings": "Restore backup (recommended)"
+ },
+ "menu_option_descriptions": {
+ "use_new_settings": "This will keep the new settings written to the stick. Only choose this option if you have intentionally changed settings.",
+ "restore_old_settings": "This will restore your network settings back to the last working state."
}
}
}
@@ -654,6 +724,9 @@
},
"reset_alarm": {
"name": "Reset alarm"
+ },
+ "calibrate_valve": {
+ "name": "Calibrate valve"
}
},
"climate": {
@@ -1185,6 +1258,33 @@
},
"tilt_position_percentage_after_move_to_level": {
"name": "Tilt position percentage after move to level"
+ },
+ "display_on_time": {
+ "name": "Display on-time"
+ },
+ "closing_duration": {
+ "name": "Closing duration"
+ },
+ "opening_duration": {
+ "name": "Opening duration"
+ },
+ "long_press_duration": {
+ "name": "Long press duration"
+ },
+ "motor_start_delay": {
+ "name": "Motor start delay"
+ },
+ "max_brightness": {
+ "name": "Maximum brightness"
+ },
+ "min_brightness": {
+ "name": "Minimum brightness"
+ },
+ "reporting_interval": {
+ "name": "Reporting interval"
+ },
+ "sensitivity": {
+ "name": "Sensitivity"
}
},
"select": {
@@ -1424,6 +1524,21 @@
},
"switch_actions": {
"name": "Switch actions"
+ },
+ "ctrl_sequence_of_oper": {
+ "name": "Control sequence"
+ },
+ "displayed_temperature": {
+ "name": "Displayed temperature"
+ },
+ "calibration_mode": {
+ "name": "Calibration mode"
+ },
+ "mode_switch": {
+ "name": "Mode switch"
+ },
+ "phase": {
+ "name": "Phase"
}
},
"sensor": {
@@ -1806,6 +1921,15 @@
},
"opening": {
"name": "Opening"
+ },
+ "operating_mode": {
+ "name": "Operating mode"
+ },
+ "valve_adapt_status": {
+ "name": "Valve adaptation status"
+ },
+ "motor_state": {
+ "name": "Motor state"
}
},
"switch": {
@@ -2036,6 +2160,21 @@
},
"frient_com_2": {
"name": "COM 2"
+ },
+ "window_open": {
+ "name": "Window open"
+ },
+ "turn_on_led_when_off": {
+ "name": "Turn on LED when off"
+ },
+ "turn_on_led_when_on": {
+ "name": "Turn on LED when on"
+ },
+ "dimmer_mode": {
+ "name": "Dimmer mode"
+ },
+ "flip_indicator_light": {
+ "name": "Flip indicator light"
}
}
}
diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py
index 217636edbd5..69065d1472b 100644
--- a/homeassistant/components/zhong_hong/climate.py
+++ b/homeassistant/components/zhong_hong/climate.py
@@ -11,6 +11,10 @@ from zhong_hong_hvac.hvac import HVAC as ZhongHongHVAC
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ FAN_MIDDLE,
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
ClimateEntity,
ClimateEntityFeature,
@@ -65,6 +69,17 @@ MODE_TO_STATE = {
ZHONG_HONG_MODE_FAN_ONLY: HVACMode.FAN_ONLY,
}
+# HA → zhong_hong
+FAN_MODE_MAP = {
+ FAN_LOW: "LOW",
+ FAN_MEDIUM: "MID",
+ FAN_HIGH: "HIGH",
+ FAN_MIDDLE: "MID",
+ "medium_high": "MIDHIGH",
+ "medium_low": "MIDLOW",
+}
+FAN_MODE_REVERSE_MAP = {v: k for k, v in FAN_MODE_MAP.items()}
+
def setup_platform(
hass: HomeAssistant,
@@ -208,12 +223,16 @@ class ZhongHongClimate(ClimateEntity):
@property
def fan_mode(self):
"""Return the fan setting."""
- return self._current_fan_mode
+ if not self._current_fan_mode:
+ return None
+ return FAN_MODE_REVERSE_MAP.get(self._current_fan_mode, self._current_fan_mode)
@property
def fan_modes(self):
"""Return the list of available fan modes."""
- return self._device.fan_list
+ if not self._device.fan_list:
+ return []
+ return list({FAN_MODE_REVERSE_MAP.get(x, x) for x in self._device.fan_list})
@property
def min_temp(self) -> float:
@@ -255,4 +274,7 @@ class ZhongHongClimate(ClimateEntity):
def set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- self._device.set_fan_mode(fan_mode)
+ mapped_mode = FAN_MODE_MAP.get(fan_mode)
+ if not mapped_mode:
+ _LOGGER.error("Unsupported fan mode: %s", fan_mode)
+ self._device.set_fan_mode(mapped_mode)
diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py
index 8f05e35e263..e39011ae0b9 100644
--- a/homeassistant/components/zimi/cover.py
+++ b/homeassistant/components/zimi/cover.py
@@ -28,9 +28,11 @@ async def async_setup_entry(
api = config_entry.runtime_data
- doors = [ZimiCover(device, api) for device in api.doors]
+ covers = [ZimiCover(device, api) for device in api.blinds]
- async_add_entities(doors)
+ covers.extend(ZimiCover(device, api) for device in api.doors)
+
+ async_add_entities(covers)
class ZimiCover(ZimiEntity, CoverEntity):
@@ -81,9 +83,9 @@ class ZimiCover(ZimiEntity, CoverEntity):
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Open the cover/door to a specified percentage."""
- if position := kwargs.get("position"):
- _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name)
- await self._device.open_to_percentage(position)
+ position = kwargs.get("position", 0)
+ _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name)
+ await self._device.open_to_percentage(position)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json
index 58a56c97830..718857c4518 100644
--- a/homeassistant/components/zimi/manifest.json
+++ b/homeassistant/components/zimi/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/zimi",
"iot_class": "local_push",
"quality_scale": "bronze",
- "requirements": ["zcc-helper==3.6"]
+ "requirements": ["zcc-helper==3.7"]
}
diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py
index cc2429ed3a4..90c6761efc5 100644
--- a/homeassistant/components/zone/condition.py
+++ b/homeassistant/components/zone/condition.py
@@ -2,14 +2,16 @@
from __future__ import annotations
+from typing import Any, cast
+
import voluptuous as vol
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
- CONF_CONDITION,
CONF_ENTITY_ID,
+ CONF_OPTIONS,
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -17,26 +19,22 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
+ ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import in_zone
-_CONDITION_SCHEMA = vol.Schema(
- {
- **cv.CONDITION_BASE_SCHEMA,
- vol.Required(CONF_CONDITION): "zone",
- vol.Required(CONF_ENTITY_ID): cv.entity_ids,
- vol.Required("zone"): cv.entity_ids,
- # To support use_trigger_value in automation
- # Deprecated 2016/04/25
- vol.Optional("event"): vol.Any("enter", "leave"),
- }
-)
+_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
+ vol.Required(CONF_ENTITY_ID): cv.entity_ids,
+ vol.Required("zone"): cv.entity_ids,
+}
+_CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT})
def zone(
@@ -95,21 +93,34 @@ def zone(
class ZoneCondition(Condition):
"""Zone condition."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize condition."""
- self._config = config
+ _options: dict[str, Any]
+
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config."""
+ complete_config = move_top_level_schema_fields_to_options(
+ complete_config, _OPTIONS_SCHEMA_DICT
+ )
+ return await super().async_validate_complete_config(hass, complete_config)
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
- return _CONDITION_SCHEMA(config) # type: ignore[no-any-return]
+ return cast(ConfigType, _CONDITION_SCHEMA(config))
+
+ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
+ """Initialize condition."""
+ assert config.options is not None
+ self._options = config.options
async def async_get_checker(self) -> ConditionCheckerType:
"""Wrap action method with zone based condition."""
- entity_ids = self._config.get(CONF_ENTITY_ID, [])
- zone_entity_ids = self._config.get(CONF_ZONE, [])
+ entity_ids = self._options.get(CONF_ENTITY_ID, [])
+ zone_entity_ids = self._options.get(CONF_ZONE, [])
@trace_condition_function
def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index af42f024e6a..2076c37856e 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -91,6 +91,7 @@ from .const import (
CONF_ADDON_S2_ACCESS_CONTROL_KEY,
CONF_ADDON_S2_AUTHENTICATED_KEY,
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
+ CONF_ADDON_SOCKET,
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
CONF_INTEGRATION_CREATED_ADDON,
@@ -102,9 +103,11 @@ from .const import (
CONF_S2_ACCESS_CONTROL_KEY,
CONF_S2_AUTHENTICATED_KEY,
CONF_S2_UNAUTHENTICATED_KEY,
+ CONF_SOCKET_PATH,
CONF_USB_PATH,
CONF_USE_ADDON,
DOMAIN,
+ ESPHOME_ADDON_VERSION,
EVENT_DEVICE_ADDED_TO_REGISTRY,
EVENT_VALUE_UPDATED,
LIB_LOGGER,
@@ -115,11 +118,7 @@ from .const import (
ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
ZWAVE_JS_VALUE_UPDATED_EVENT,
)
-from .discovery import (
- ZwaveDiscoveryInfo,
- async_discover_node_values,
- async_discover_single_value,
-)
+from .discovery import async_discover_node_values, async_discover_single_value
from .helpers import (
async_disable_server_logging_if_needed,
async_enable_server_logging_if_needed,
@@ -131,7 +130,7 @@ from .helpers import (
get_valueless_base_unique_id,
)
from .migrate import async_migrate_discovered_value
-from .models import ZwaveJSConfigEntry, ZwaveJSData
+from .models import PlatformZwaveDiscoveryInfo, ZwaveJSConfigEntry, ZwaveJSData
from .services import async_setup_services
CONNECT_TIMEOUT = 10
@@ -776,7 +775,7 @@ class NodeEvents:
# Remove any old value ids if this is a reinterview.
self.controller_events.discovered_value_ids.pop(device.id, None)
- value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}
+ value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo] = {}
# run discovery on all node values and create/update entities
await asyncio.gather(
@@ -858,8 +857,8 @@ class NodeEvents:
async def async_handle_discovery_info(
self,
device: dr.DeviceEntry,
- disc_info: ZwaveDiscoveryInfo,
- value_updates_disc_info: dict[str, ZwaveDiscoveryInfo],
+ disc_info: PlatformZwaveDiscoveryInfo,
+ value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo],
) -> None:
"""Handle discovery info and all dependent tasks."""
platform = disc_info.platform
@@ -901,7 +900,9 @@ class NodeEvents:
)
async def async_on_value_added(
- self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
+ self,
+ value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo],
+ value: Value,
) -> None:
"""Fire value updated event."""
# If node isn't ready or a device for this node doesn't already exist, we can
@@ -1036,7 +1037,9 @@ class NodeEvents:
@callback
def async_on_value_updated_fire_event(
- self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
+ self,
+ value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo],
+ value: Value,
) -> None:
"""Fire value updated event."""
# Get the discovery info for the value that was updated. If there is
@@ -1174,7 +1177,16 @@ async def async_ensure_addon_running(
except AddonError as err:
raise ConfigEntryNotReady(err) from err
- usb_path: str = entry.data[CONF_USB_PATH]
+ addon_has_lr = (
+ addon_info.version and AwesomeVersion(addon_info.version) >= LR_ADDON_VERSION
+ )
+ addon_has_esphome = (
+ addon_info.version
+ and AwesomeVersion(addon_info.version) >= ESPHOME_ADDON_VERSION
+ )
+
+ usb_path: str | None = entry.data[CONF_USB_PATH]
+ socket_path: str | None = entry.data.get(CONF_SOCKET_PATH)
# s0_legacy_key was saved as network_key before s2 was added.
s0_legacy_key: str = entry.data.get(CONF_S0_LEGACY_KEY, "")
if not s0_legacy_key:
@@ -1186,15 +1198,18 @@ async def async_ensure_addon_running(
lr_s2_authenticated_key: str = entry.data.get(CONF_LR_S2_AUTHENTICATED_KEY, "")
addon_state = addon_info.state
addon_config = {
- CONF_ADDON_DEVICE: usb_path,
CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key,
CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key,
CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key,
}
- if addon_info.version and AwesomeVersion(addon_info.version) >= LR_ADDON_VERSION:
+ if usb_path is not None:
+ addon_config[CONF_ADDON_DEVICE] = usb_path
+ if addon_has_lr:
addon_config[CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY] = lr_s2_access_control_key
addon_config[CONF_ADDON_LR_S2_AUTHENTICATED_KEY] = lr_s2_authenticated_key
+ if addon_has_esphome and socket_path is not None:
+ addon_config[CONF_ADDON_SOCKET] = socket_path
if addon_state == AddonState.NOT_INSTALLED:
addon_manager.async_schedule_install_setup_addon(
@@ -1211,7 +1226,7 @@ async def async_ensure_addon_running(
raise ConfigEntryNotReady
addon_options = addon_info.options
- addon_device = addon_options[CONF_ADDON_DEVICE]
+ addon_device = addon_options.get(CONF_ADDON_DEVICE)
# s0_legacy_key was saved as network_key before s2 was added.
addon_s0_legacy_key = addon_options.get(CONF_ADDON_S0_LEGACY_KEY, "")
if not addon_s0_legacy_key:
@@ -1235,9 +1250,7 @@ async def async_ensure_addon_running(
if s2_unauthenticated_key != addon_s2_unauthenticated_key:
updates[CONF_S2_UNAUTHENTICATED_KEY] = addon_s2_unauthenticated_key
- if addon_info.version and AwesomeVersion(addon_info.version) >= AwesomeVersion(
- LR_ADDON_VERSION
- ):
+ if addon_has_lr:
addon_lr_s2_access_control_key = addon_options.get(
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, ""
)
@@ -1249,6 +1262,11 @@ async def async_ensure_addon_running(
if lr_s2_authenticated_key != addon_lr_s2_authenticated_key:
updates[CONF_LR_S2_AUTHENTICATED_KEY] = addon_lr_s2_authenticated_key
+ if addon_has_esphome:
+ addon_socket = addon_options.get(CONF_ADDON_SOCKET)
+ if socket_path != addon_socket:
+ updates[CONF_SOCKET_PATH] = addon_socket
+
if updates:
hass.config_entries.async_update_entry(entry, data={**entry.data, **updates})
diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py
index 5b7fe4f4d7c..fcb62ba9a80 100644
--- a/homeassistant/components/zwave_js/binary_sensor.py
+++ b/homeassistant/components/zwave_js/binary_sensor.py
@@ -2,12 +2,16 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, cast
from zwave_js_server.const import CommandClass
from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY
from zwave_js_server.const.command_class.notification import (
CC_SPECIFIC_NOTIFICATION_TYPE,
+ NotificationEvent,
+ NotificationType,
+ SmokeAlarmNotificationEvent,
)
from zwave_js_server.model.driver import Driver
@@ -17,15 +21,21 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
-from .discovery import ZwaveDiscoveryInfo
-from .entity import ZWaveBaseEntity
-from .models import ZwaveJSConfigEntry
+from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
+from .models import (
+ NewZWaveDiscoverySchema,
+ ValueType,
+ ZwaveDiscoveryInfo,
+ ZwaveJSConfigEntry,
+ ZWaveValueDiscoverySchema,
+)
PARALLEL_UPDATES = 0
@@ -50,12 +60,12 @@ NOTIFICATION_IRRIGATION = "17"
NOTIFICATION_GAS = "18"
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
"""Represent a Z-Wave JS binary sensor entity description."""
- off_state: str = "0"
- states: tuple[str, ...] | None = None
+ not_states: set[NotificationEvent | int] = field(default_factory=lambda: {0})
+ states: set[NotificationEvent | int] | None = None
@dataclass(frozen=True, kw_only=True)
@@ -65,6 +75,13 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription):
on_states: tuple[str, ...]
+@dataclass(frozen=True, kw_only=True)
+class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
+ """Represent a Z-Wave JS binary sensor entity description."""
+
+ state_key: str
+
+
# Mappings for Notification sensors
# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx
#
@@ -105,35 +122,24 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription):
# - Replace water filter
# - Sump pump failure
+
+# This set can be removed once all notification sensors have been migrated
+# to use the new discovery schema and we've removed the old discovery code.
+MIGRATED_NOTIFICATION_TYPES = {
+ NotificationType.SMOKE_ALARM,
+}
+
NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = (
- NotificationZWaveJSEntityDescription(
- # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected
- key=NOTIFICATION_SMOKE_ALARM,
- states=("1", "2"),
- device_class=BinarySensorDeviceClass.SMOKE,
- ),
- NotificationZWaveJSEntityDescription(
- # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8
- key=NOTIFICATION_SMOKE_ALARM,
- states=("4", "5", "7", "8"),
- device_class=BinarySensorDeviceClass.PROBLEM,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- NotificationZWaveJSEntityDescription(
- # NotificationType 1: Smoke Alarm - All other State Id's
- key=NOTIFICATION_SMOKE_ALARM,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
NotificationZWaveJSEntityDescription(
# NotificationType 2: Carbon Monoxide - State Id's 1 and 2
key=NOTIFICATION_CARBON_MONOOXIDE,
- states=("1", "2"),
+ states={1, 2},
device_class=BinarySensorDeviceClass.CO,
),
NotificationZWaveJSEntityDescription(
# NotificationType 2: Carbon Monoxide - State Id 4, 5, 7
key=NOTIFICATION_CARBON_MONOOXIDE,
- states=("4", "5", "7"),
+ states={4, 5, 7},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -145,13 +151,13 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
NotificationZWaveJSEntityDescription(
# NotificationType 3: Carbon Dioxide - State Id's 1 and 2
key=NOTIFICATION_CARBON_DIOXIDE,
- states=("1", "2"),
+ states={1, 2},
device_class=BinarySensorDeviceClass.GAS,
),
NotificationZWaveJSEntityDescription(
# NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7
key=NOTIFICATION_CARBON_DIOXIDE,
- states=("4", "5", "7"),
+ states={4, 5, 7},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -163,13 +169,13 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat)
key=NOTIFICATION_HEAT,
- states=("1", "2", "5", "6"),
+ states={1, 2, 5, 6},
device_class=BinarySensorDeviceClass.HEAT,
),
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - State ID's 8, A, B
key=NOTIFICATION_HEAT,
- states=("8", "10", "11"),
+ states={8, 10, 11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -181,13 +187,13 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
NotificationZWaveJSEntityDescription(
# NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A
key=NOTIFICATION_WATER,
- states=("1", "2", "3", "4", "6", "7", "8", "9", "10"),
+ states={1, 2, 3, 4, 6, 7, 8, 9, 10},
device_class=BinarySensorDeviceClass.MOISTURE,
),
NotificationZWaveJSEntityDescription(
# NotificationType 5: Water - State Id's B
key=NOTIFICATION_WATER,
- states=("11",),
+ states={11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -199,54 +205,54 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
key=NOTIFICATION_ACCESS_CONTROL,
- states=("1", "2", "3", "4"),
+ states={1, 2, 3, 4},
device_class=BinarySensorDeviceClass.LOCK,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 11 (Lock jammed)
key=NOTIFICATION_ACCESS_CONTROL,
- states=("11",),
+ states={11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id 22 (door/window open)
key=NOTIFICATION_ACCESS_CONTROL,
- off_state="23",
- states=("22", "23"),
+ not_states={23},
+ states={22},
device_class=BinarySensorDeviceClass.DOOR,
),
NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id's 1, 2 (intrusion)
key=NOTIFICATION_HOME_SECURITY,
- states=("1", "2"),
+ states={1, 2},
device_class=BinarySensorDeviceClass.SAFETY,
),
NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id's 3, 4, 9 (tampering)
key=NOTIFICATION_HOME_SECURITY,
- states=("3", "4", "9"),
+ states={3, 4, 9},
device_class=BinarySensorDeviceClass.TAMPER,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id's 5, 6 (glass breakage)
key=NOTIFICATION_HOME_SECURITY,
- states=("5", "6"),
+ states={5, 6},
device_class=BinarySensorDeviceClass.SAFETY,
),
NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id's 7, 8 (motion)
key=NOTIFICATION_HOME_SECURITY,
- states=("7", "8"),
+ states={7, 8},
device_class=BinarySensorDeviceClass.MOTION,
),
NotificationZWaveJSEntityDescription(
# NotificationType 8: Power Management -
# State Id's 2, 3 (Mains status)
key=NOTIFICATION_POWER_MANAGEMENT,
- off_state="2",
- states=("2", "3"),
+ not_states={2},
+ states={3},
device_class=BinarySensorDeviceClass.PLUG,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -254,7 +260,7 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
# NotificationType 8: Power Management -
# State Id's 6, 7, 8, 9 (power status)
key=NOTIFICATION_POWER_MANAGEMENT,
- states=("6", "7", "8", "9"),
+ states={6, 7, 8, 9},
device_class=BinarySensorDeviceClass.SAFETY,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -262,39 +268,39 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
# NotificationType 8: Power Management -
# State Id's 10, 11, 17 (Battery maintenance status)
key=NOTIFICATION_POWER_MANAGEMENT,
- states=("10", "11", "17"),
+ states={10, 11, 17},
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 9: System - State Id's 1, 2, 3, 4, 6, 7
key=NOTIFICATION_SYSTEM,
- states=("1", "2", "3", "4", "6", "7"),
+ states={1, 2, 3, 4, 6, 7},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 10: Emergency - State Id's 1, 2, 3
key=NOTIFICATION_EMERGENCY,
- states=("1", "2", "3"),
+ states={1, 2, 3},
device_class=BinarySensorDeviceClass.PROBLEM,
),
NotificationZWaveJSEntityDescription(
# NotificationType 14: Siren
key=NOTIFICATION_SIREN,
- states=("1",),
+ states={1},
device_class=BinarySensorDeviceClass.SOUND,
),
NotificationZWaveJSEntityDescription(
# NotificationType 18: Gas - State Id's 1, 2, 3, 4
key=NOTIFICATION_GAS,
- states=("1", "2", "3", "4"),
+ states={1, 2, 3, 4},
device_class=BinarySensorDeviceClass.GAS,
),
NotificationZWaveJSEntityDescription(
# NotificationType 18: Gas - State Id 6
key=NOTIFICATION_GAS,
- states=("6",),
+ states={6},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -353,7 +359,7 @@ BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescripti
@callback
def is_valid_notification_binary_sensor(
- info: ZwaveDiscoveryInfo,
+ info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo,
) -> bool | NotificationZWaveJSEntityDescription:
"""Return if the notification CC Value is valid as binary sensor."""
if not info.primary_value.metadata.states:
@@ -370,18 +376,49 @@ async def async_setup_entry(
client = config_entry.runtime_data.client
@callback
- def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None:
+ def async_add_binary_sensor(
+ info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo,
+ ) -> None:
"""Add Z-Wave Binary Sensor."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
- entities: list[BinarySensorEntity] = []
+ entities: list[Entity] = []
- if info.platform_hint == "notification":
+ if (
+ isinstance(info, NewZwaveDiscoveryInfo)
+ and info.entity_class is ZWaveNotificationBinarySensor
+ and isinstance(
+ info.entity_description, NotificationZWaveJSEntityDescription
+ )
+ and is_valid_notification_binary_sensor(info)
+ ):
+ entities.extend(
+ ZWaveNotificationBinarySensor(
+ config_entry, driver, info, state_key, info.entity_description
+ )
+ for state_key in info.primary_value.metadata.states
+ if int(state_key) not in info.entity_description.not_states
+ and (
+ not info.entity_description.states
+ or int(state_key) in info.entity_description.states
+ )
+ )
+ elif isinstance(info, NewZwaveDiscoveryInfo):
+ pass # other entity classes are not migrated yet
+ elif info.platform_hint == "notification":
# ensure the notification CC Value is valid as binary sensor
if not is_valid_notification_binary_sensor(info):
return
+ if (
+ notification_type := info.primary_value.metadata.cc_specific[
+ CC_SPECIFIC_NOTIFICATION_TYPE
+ ]
+ ) in MIGRATED_NOTIFICATION_TYPES:
+ return
# Get all sensors from Notification CC states
for state_key in info.primary_value.metadata.states:
+ if TYPE_CHECKING:
+ state_key = cast(str, state_key)
# ignore idle key (0)
if state_key == "0":
continue
@@ -390,18 +427,15 @@ async def async_setup_entry(
NotificationZWaveJSEntityDescription | None
) = None
for description in NOTIFICATION_SENSOR_MAPPINGS:
- if (
- int(description.key)
- == info.primary_value.metadata.cc_specific[
- CC_SPECIFIC_NOTIFICATION_TYPE
- ]
- ) and (not description.states or state_key in description.states):
+ if (int(description.key) == notification_type) and (
+ not description.states or int(state_key) in description.states
+ ):
notification_description = description
break
if (
notification_description
- and notification_description.off_state == state_key
+ and int(state_key) in notification_description.not_states
):
continue
entities.append(
@@ -477,7 +511,7 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
self,
config_entry: ZwaveJSConfigEntry,
driver: Driver,
- info: ZwaveDiscoveryInfo,
+ info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo,
state_key: str,
description: NotificationZWaveJSEntityDescription | None = None,
) -> None:
@@ -543,3 +577,93 @@ class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor):
alternate_value_name=self.info.primary_value.property_name,
additional_info=[property_key_name] if property_key_name else None,
)
+
+
+DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
+ NewZWaveDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={
+ CommandClass.NOTIFICATION,
+ },
+ type={ValueType.NUMBER},
+ any_available_states_keys={
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED_LOCATION_PROVIDED,
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED,
+ },
+ any_available_cc_specific={
+ (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.SMOKE_ALARM)
+ },
+ ),
+ allow_multi=True,
+ entity_description=NotificationZWaveJSEntityDescription(
+ # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected
+ key=NOTIFICATION_SMOKE_ALARM,
+ states={
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED_LOCATION_PROVIDED,
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED,
+ },
+ device_class=BinarySensorDeviceClass.SMOKE,
+ ),
+ entity_class=ZWaveNotificationBinarySensor,
+ ),
+ NewZWaveDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={
+ CommandClass.NOTIFICATION,
+ },
+ type={ValueType.NUMBER},
+ any_available_states_keys={
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED,
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
+ SmokeAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
+ SmokeAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
+ },
+ any_available_cc_specific={
+ (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.SMOKE_ALARM)
+ },
+ ),
+ allow_multi=True,
+ entity_description=NotificationZWaveJSEntityDescription(
+ # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8
+ key=NOTIFICATION_SMOKE_ALARM,
+ states={
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED,
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
+ SmokeAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
+ SmokeAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
+ },
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ entity_class=ZWaveNotificationBinarySensor,
+ ),
+ NewZWaveDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ primary_value=ZWaveValueDiscoverySchema(
+ command_class={
+ CommandClass.NOTIFICATION,
+ },
+ type={ValueType.NUMBER},
+ any_available_cc_specific={
+ (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.SMOKE_ALARM)
+ },
+ ),
+ allow_multi=True,
+ entity_description=NotificationZWaveJSEntityDescription(
+ # NotificationType 1: Smoke Alarm - All other State Id's
+ key=NOTIFICATION_SMOKE_ALARM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ not_states={
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED_LOCATION_PROVIDED,
+ SmokeAlarmNotificationEvent.SENSOR_STATUS_SMOKE_DETECTED,
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED,
+ SmokeAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
+ SmokeAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
+ SmokeAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
+ },
+ ),
+ entity_class=ZWaveNotificationBinarySensor,
+ ),
+]
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index 92912a2cdb5..71a349916d3 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -26,6 +26,7 @@ from homeassistant.components.hassio import (
AddonState,
)
from homeassistant.config_entries import (
+ SOURCE_ESPHOME,
SOURCE_USB,
ConfigEntryState,
ConfigFlow,
@@ -37,6 +38,7 @@ from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from homeassistant.helpers.hassio import is_hassio
+from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -52,6 +54,7 @@ from .const import (
CONF_ADDON_S2_ACCESS_CONTROL_KEY,
CONF_ADDON_S2_AUTHENTICATED_KEY,
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
+ CONF_ADDON_SOCKET,
CONF_INTEGRATION_CREATED_ADDON,
CONF_KEEP_OLD_DEVICES,
CONF_LR_S2_ACCESS_CONTROL_KEY,
@@ -60,6 +63,7 @@ from .const import (
CONF_S2_ACCESS_CONTROL_KEY,
CONF_S2_AUTHENTICATED_KEY,
CONF_S2_UNAUTHENTICATED_KEY,
+ CONF_SOCKET_PATH,
CONF_USB_PATH,
CONF_USE_ADDON,
DOMAIN,
@@ -81,6 +85,7 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 40
ADDON_USER_INPUT_MAP = {
CONF_ADDON_DEVICE: CONF_USB_PATH,
+ CONF_ADDON_SOCKET: CONF_SOCKET_PATH,
CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY,
CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY,
@@ -129,7 +134,7 @@ def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
def get_on_supervisor_schema(user_input: dict[str, Any]) -> vol.Schema:
"""Return a schema for the on Supervisor step."""
default_use_addon = user_input[CONF_USE_ADDON]
- return vol.Schema({vol.Optional(CONF_USE_ADDON, default=default_use_addon): bool})
+ return vol.Schema({vol.Required(CONF_USE_ADDON, default=default_use_addon): bool})
async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo:
@@ -197,6 +202,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.lr_s2_access_control_key: str | None = None
self.lr_s2_authenticated_key: str | None = None
self.usb_path: str | None = None
+ self.socket_path: str | None = None # ESPHome socket
self.ws_address: str | None = None
self.restart_addon: bool = False
# If we install the add-on we should uninstall it on entry remove.
@@ -214,7 +220,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._addon_config_updates: dict[str, Any] = {}
self._migrating = False
self._reconfigure_config_entry: ZwaveJSConfigEntry | None = None
- self._usb_discovery = False
+ self._adapter_discovered = False
self._recommended_install = False
self._rf_region: str | None = None
@@ -370,6 +376,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
new_addon_config = addon_config | config_updates
+ if new_addon_config.get(CONF_ADDON_DEVICE) is None:
+ new_addon_config.pop(CONF_ADDON_DEVICE, None)
+ if new_addon_config.get(CONF_ADDON_SOCKET) is None:
+ new_addon_config.pop(CONF_ADDON_SOCKET, None)
+
if new_addon_config == addon_config:
return
@@ -542,7 +553,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
title = human_name.split(" - ")[0].strip()
self.context["title_placeholders"] = {CONF_NAME: title}
- self._usb_discovery = True
+ self._adapter_discovered = True
if current_config_entries:
return await self.async_step_confirm_usb_migration()
@@ -658,7 +669,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select custom installation type."""
- if self._usb_discovery:
+ if self._adapter_discovered:
return await self.async_step_on_supervisor({CONF_USE_ADDON: True})
return await self.async_step_on_supervisor()
@@ -692,7 +703,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_on_supervisor(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle logic when on Supervisor host."""
+ """Handle logic when on Supervisor host.
+
+ When the add-on is running, we copy over it's settings.
+ We will ignore settings for USB/Socket if those were discovered.
+
+ If add-on is not running, we will configure the add-on.
+
+ When it's not installed, we install it with new config options.
+ """
if user_input is None:
return self.async_show_form(
step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA
@@ -706,7 +725,11 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
if addon_info.state == AddonState.RUNNING:
addon_config = addon_info.options
- self.usb_path = addon_config[CONF_ADDON_DEVICE]
+ # Use the options set by USB/ESPHome discovery
+ if not self._adapter_discovered:
+ self.usb_path = addon_config.get(CONF_ADDON_DEVICE)
+ self.socket_path = addon_config.get(CONF_ADDON_SOCKET)
+
self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "")
self.s2_access_control_key = addon_config.get(
CONF_ADDON_S2_ACCESS_CONTROL_KEY, ""
@@ -736,14 +759,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Ask for config for Z-Wave JS add-on."""
if user_input is not None:
- self.usb_path = user_input[CONF_USB_PATH]
+ self.usb_path = user_input.get(CONF_USB_PATH)
+ self.socket_path = user_input.get(CONF_SOCKET_PATH)
return await self.async_step_network_type()
- if self._usb_discovery:
+ if self._adapter_discovered:
return await self.async_step_network_type()
- usb_path = self.usb_path or ""
-
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
@@ -752,7 +774,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
- vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
+ vol.Optional(
+ CONF_USB_PATH, description={"suggested_value": self.usb_path}
+ ): vol.In(ports),
+ vol.Optional(
+ CONF_SOCKET_PATH,
+ description={"suggested_value": self.socket_path or ""},
+ ): str,
}
)
@@ -780,6 +808,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
addon_config_updates = {
CONF_ADDON_DEVICE: self.usb_path,
+ CONF_ADDON_SOCKET: self.socket_path,
CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -851,6 +880,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
addon_config_updates = {
CONF_ADDON_DEVICE: self.usb_path,
+ CONF_ADDON_SOCKET: self.socket_path,
CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -912,17 +942,38 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
str(self.version_info.home_id), raise_on_progress=False
)
+ # When we came from discovery, make sure we update the add-on
+ if self._adapter_discovered and self.use_addon:
+ await self._async_set_addon_config(
+ {
+ CONF_ADDON_DEVICE: self.usb_path,
+ CONF_ADDON_SOCKET: self.socket_path,
+ CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key,
+ CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
+ CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
+ CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key,
+ CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key,
+ CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key,
+ }
+ )
+
self._abort_if_unique_id_configured(
updates={
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
+ CONF_SOCKET_PATH: self.socket_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key,
CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key,
CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key,
- }
+ },
+ error=(
+ "migration_successful"
+ if self.source in (SOURCE_USB, SOURCE_ESPHOME)
+ else "already_configured"
+ ),
)
return self._async_create_entry_from_vars()
@@ -938,6 +989,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
+ CONF_SOCKET_PATH: self.socket_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -974,7 +1026,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Confirm the user wants to reset their current controller."""
config_entry = self._reconfigure_config_entry
assert config_entry is not None
- if not self._usb_discovery and not config_entry.data.get(CONF_USE_ADDON):
+ if not self._adapter_discovered and not config_entry.data.get(CONF_USE_ADDON):
return self.async_abort(
reason="addon_required",
description_placeholders={
@@ -1062,9 +1114,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Instruct the user to unplug the old controller."""
if user_input is not None:
- if self.usb_path:
- # USB discovery was used, so the device is already known.
+ if self._adapter_discovered:
+ # Discovery was used, so the device is already known.
self._addon_config_updates[CONF_ADDON_DEVICE] = self.usb_path
+ self._addon_config_updates[CONF_ADDON_SOCKET] = self.socket_path
return await self.async_step_start_addon()
# Now that the old controller is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
@@ -1184,10 +1237,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY]
self.lr_s2_access_control_key = user_input[CONF_LR_S2_ACCESS_CONTROL_KEY]
self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY]
- self.usb_path = user_input[CONF_USB_PATH]
+ self.usb_path = user_input.get(CONF_USB_PATH)
+ self.socket_path = user_input.get(CONF_SOCKET_PATH)
addon_config_updates = {
CONF_ADDON_DEVICE: self.usb_path,
+ CONF_ADDON_SOCKET: self.socket_path,
CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -1198,6 +1253,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
addon_config_updates = self._addon_config_updates | addon_config_updates
self._addon_config_updates = {}
+
await self._async_set_addon_config(addon_config_updates)
if addon_info.state == AddonState.RUNNING and not self.restart_addon:
@@ -1212,6 +1268,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_start_addon()
usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "")
+ socket_path = addon_config.get(CONF_ADDON_SOCKET, self.socket_path or "")
s0_legacy_key = addon_config.get(
CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or ""
)
@@ -1237,24 +1294,42 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
+ # Insert empty option in ports to allow setting a socket
+ ports = {
+ "": "Use Socket",
+ **ports,
+ }
+
data_schema = vol.Schema(
{
- vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
- vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str,
vol.Optional(
- CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key
+ CONF_USB_PATH, description={"suggested_value": usb_path}
+ ): vol.In(ports),
+ vol.Optional(
+ CONF_SOCKET_PATH, description={"suggested_value": socket_path}
): str,
vol.Optional(
- CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key
+ CONF_S0_LEGACY_KEY, description={"suggested_value": s0_legacy_key}
): str,
vol.Optional(
- CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key
+ CONF_S2_ACCESS_CONTROL_KEY,
+ description={"suggested_value": s2_access_control_key},
): str,
vol.Optional(
- CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key
+ CONF_S2_AUTHENTICATED_KEY,
+ description={"suggested_value": s2_authenticated_key},
): str,
vol.Optional(
- CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key
+ CONF_S2_UNAUTHENTICATED_KEY,
+ description={"suggested_value": s2_unauthenticated_key},
+ ): str,
+ vol.Optional(
+ CONF_LR_S2_ACCESS_CONTROL_KEY,
+ description={"suggested_value": lr_s2_access_control_key},
+ ): str,
+ vol.Optional(
+ CONF_LR_S2_AUTHENTICATED_KEY,
+ description={"suggested_value": lr_s2_authenticated_key},
): str,
}
)
@@ -1268,8 +1343,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Choose a serial port."""
if user_input is not None:
- self.usb_path = user_input[CONF_USB_PATH]
+ self.usb_path = user_input.get(CONF_USB_PATH)
+ self.socket_path = user_input.get(CONF_SOCKET_PATH)
self._addon_config_updates[CONF_ADDON_DEVICE] = self.usb_path
+ self._addon_config_updates[CONF_ADDON_SOCKET] = self.socket_path
return await self.async_step_start_addon()
try:
@@ -1286,10 +1363,16 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
await self.hass.async_add_executor_job(usb.get_serial_by_id, old_usb_path),
None,
)
+ # Insert empty option in ports to allow setting a socket
+ ports = {
+ "": "Use Socket",
+ **ports,
+ }
data_schema = vol.Schema(
{
- vol.Required(CONF_USB_PATH): vol.In(ports),
+ vol.Optional(CONF_USB_PATH): vol.In(ports),
+ vol.Optional(CONF_SOCKET_PATH): str,
}
)
return self.async_show_form(
@@ -1347,6 +1430,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
**config_entry.data,
CONF_URL: ws_address,
CONF_USB_PATH: self.usb_path,
+ CONF_SOCKET_PATH: self.socket_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -1396,6 +1480,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
{
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
+ CONF_SOCKET_PATH: self.socket_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key,
CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key,
@@ -1409,6 +1494,57 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reconfigure_successful")
+ async def async_step_esphome(
+ self, discovery_info: ESPHomeServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a ESPHome discovery."""
+ if not is_hassio(self.hass):
+ return self.async_abort(reason="not_hassio")
+
+ if discovery_info.zwave_home_id:
+ if (
+ (
+ current_config_entries := self._async_current_entries(
+ include_ignore=False
+ )
+ )
+ and (home_id := str(discovery_info.zwave_home_id))
+ and (
+ existing_entry := next(
+ (
+ entry
+ for entry in current_config_entries
+ if entry.unique_id == home_id
+ ),
+ None,
+ )
+ )
+ # Only update existing entries that are configured via sockets
+ and existing_entry.data.get(CONF_SOCKET_PATH)
+ # And use the add-on
+ and existing_entry.data.get(CONF_USE_ADDON)
+ ):
+ await self._async_set_addon_config(
+ {CONF_ADDON_SOCKET: discovery_info.socket_path}
+ )
+ # Reloading will sync add-on options to config entry data
+ self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
+ return self.async_abort(reason="already_configured")
+
+ # We are not aborting if home ID configured here, we just want to make sure that it's set
+ # We will update a USB based config entry automatically in `async_step_finish_addon_setup_user`
+ await self.async_set_unique_id(
+ str(discovery_info.zwave_home_id), raise_on_progress=False
+ )
+
+ self.socket_path = discovery_info.socket_path
+ self.context["title_placeholders"] = {
+ CONF_NAME: f"{discovery_info.name} via ESPHome"
+ }
+ self._adapter_discovered = True
+
+ return await self.async_step_installation_type()
+
async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult:
"""Abort the options flow.
diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py
index 69987385d5a..951f312516d 100644
--- a/homeassistant/components/zwave_js/const.py
+++ b/homeassistant/components/zwave_js/const.py
@@ -12,6 +12,7 @@ from zwave_js_server.const.command_class.window_covering import (
from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION
LR_ADDON_VERSION = AwesomeVersion("0.5.0")
+ESPHOME_ADDON_VERSION = AwesomeVersion("0.24.0")
USER_AGENT = {APPLICATION_NAME: HA_VERSION}
@@ -23,6 +24,7 @@ CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key"
CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
+CONF_ADDON_SOCKET = "socket"
CONF_INSTALLER_MODE = "installer_mode"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
CONF_KEEP_OLD_DEVICES = "keep_old_devices"
@@ -33,6 +35,7 @@ CONF_S2_AUTHENTICATED_KEY = "s2_authenticated_key"
CONF_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
CONF_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
CONF_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
+CONF_SOCKET_PATH = "socket_path"
CONF_USB_PATH = "usb_path"
CONF_USE_ADDON = "use_addon"
CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in"
diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py
index 424fe94b8b9..d468a233f05 100644
--- a/homeassistant/components/zwave_js/cover.py
+++ b/homeassistant/components/zwave_js/cover.py
@@ -299,11 +299,23 @@ class ZWaveMultilevelSwitchCover(CoverPositionMixin):
# Entity class attributes
self._attr_device_class = CoverDeviceClass.WINDOW
- if self.info.platform_hint and self.info.platform_hint.startswith("shutter"):
+ if (
+ isinstance(self.info, ZwaveDiscoveryInfo)
+ and self.info.platform_hint
+ and self.info.platform_hint.startswith("shutter")
+ ):
self._attr_device_class = CoverDeviceClass.SHUTTER
- elif self.info.platform_hint and self.info.platform_hint.startswith("blind"):
+ elif (
+ isinstance(self.info, ZwaveDiscoveryInfo)
+ and self.info.platform_hint
+ and self.info.platform_hint.startswith("blind")
+ ):
self._attr_device_class = CoverDeviceClass.BLIND
- elif self.info.platform_hint and self.info.platform_hint.startswith("gate"):
+ elif (
+ isinstance(self.info, ZwaveDiscoveryInfo)
+ and self.info.platform_hint
+ and self.info.platform_hint.startswith("gate")
+ ):
self._attr_device_class = CoverDeviceClass.GATE
diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py
index 1358c3aca96..a9775873f0c 100644
--- a/homeassistant/components/zwave_js/device_trigger.py
+++ b/homeassistant/components/zwave_js/device_trigger.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
+ CONF_OPTIONS,
CONF_PLATFORM,
CONF_TYPE,
)
@@ -434,12 +435,13 @@ async def async_attach_trigger(
if trigger_platform == VALUE_UPDATED_PLATFORM_TYPE:
zwave_js_config = {
- state.CONF_PLATFORM: trigger_platform,
- CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+ CONF_OPTIONS: {
+ CONF_DEVICE_ID: config[CONF_DEVICE_ID],
+ },
}
copy_available_params(
config,
- zwave_js_config,
+ zwave_js_config[CONF_OPTIONS],
[
ATTR_COMMAND_CLASS,
ATTR_PROPERTY,
@@ -453,7 +455,7 @@ async def async_attach_trigger(
hass, zwave_js_config
)
return await attach_value_updated_trigger(
- hass, zwave_js_config, action, trigger_info
+ hass, zwave_js_config[CONF_OPTIONS], action, trigger_info
)
raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py
index 7030009f5ad..858e4c300b8 100644
--- a/homeassistant/components/zwave_js/discovery.py
+++ b/homeassistant/components/zwave_js/discovery.py
@@ -3,9 +3,8 @@
from __future__ import annotations
from collections.abc import Generator
-from dataclasses import asdict, dataclass, field
-from enum import StrEnum
-from typing import TYPE_CHECKING, Any, cast
+from dataclasses import dataclass
+from typing import cast
from awesomeversion import AwesomeVersion
from zwave_js_server.const import (
@@ -55,6 +54,7 @@ from homeassistant.const import EntityCategory, Platform
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntry
+from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, LOGGER
from .discovery_data_template import (
BaseDiscoverySchemaDataTemplate,
@@ -65,108 +65,20 @@ from .discovery_data_template import (
FixedFanValueMappingDataTemplate,
NumericSensorDataTemplate,
)
-from .helpers import ZwaveValueID
+from .entity import NewZwaveDiscoveryInfo
+from .models import (
+ FirmwareVersionRange,
+ NewZWaveDiscoverySchema,
+ ValueType,
+ ZwaveDiscoveryInfo,
+ ZWaveValueDiscoverySchema,
+ ZwaveValueID,
+)
-if TYPE_CHECKING:
- from _typeshed import DataclassInstance
-
-
-class ValueType(StrEnum):
- """Enum with all value types."""
-
- ANY = "any"
- BOOLEAN = "boolean"
- NUMBER = "number"
- STRING = "string"
-
-
-class DataclassMustHaveAtLeastOne:
- """A dataclass that must have at least one input parameter that is not None."""
-
- def __post_init__(self: DataclassInstance) -> None:
- """Post dataclass initialization."""
- if all(val is None for val in asdict(self).values()):
- raise ValueError("At least one input parameter must not be None")
-
-
-@dataclass
-class FirmwareVersionRange(DataclassMustHaveAtLeastOne):
- """Firmware version range dictionary."""
-
- min: str | None = None
- max: str | None = None
- min_ver: AwesomeVersion | None = field(default=None, init=False)
- max_ver: AwesomeVersion | None = field(default=None, init=False)
-
- def __post_init__(self) -> None:
- """Post dataclass initialization."""
- super().__post_init__()
- if self.min:
- self.min_ver = AwesomeVersion(self.min)
- if self.max:
- self.max_ver = AwesomeVersion(self.max)
-
-
-@dataclass
-class ZwaveDiscoveryInfo:
- """Info discovered from (primary) ZWave Value to create entity."""
-
- # node to which the value(s) belongs
- node: ZwaveNode
- # the value object itself for primary value
- primary_value: ZwaveValue
- # bool to specify whether state is assumed and events should be fired on value
- # update
- assumed_state: bool
- # the home assistant platform for which an entity should be created
- platform: Platform
- # helper data to use in platform setup
- platform_data: Any
- # additional values that need to be watched by entity
- additional_value_ids_to_watch: set[str]
- # hint for the platform about this discovered entity
- platform_hint: str | None = ""
- # data template to use in platform logic
- platform_data_template: BaseDiscoverySchemaDataTemplate | None = None
- # bool to specify whether entity should be enabled by default
- entity_registry_enabled_default: bool = True
- # the entity category for the discovered entity
- entity_category: EntityCategory | None = None
-
-
-@dataclass
-class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne):
- """Z-Wave Value discovery schema.
-
- The Z-Wave Value must match these conditions.
- Use the Z-Wave specifications to find out the values for these parameters:
- https://github.com/zwave-js/specs/tree/master
- """
-
- # [optional] the value's command class must match ANY of these values
- command_class: set[int] | None = None
- # [optional] the value's endpoint must match ANY of these values
- endpoint: set[int] | None = None
- # [optional] the value's property must match ANY of these values
- property: set[str | int] | None = None
- # [optional] the value's property name must match ANY of these values
- property_name: set[str] | None = None
- # [optional] the value's property key must match ANY of these values
- property_key: set[str | int | None] | None = None
- # [optional] the value's property key must NOT match ANY of these values
- not_property_key: set[str | int | None] | None = None
- # [optional] the value's metadata_type must match ANY of these values
- type: set[str] | None = None
- # [optional] the value's metadata_readable must match this value
- readable: bool | None = None
- # [optional] the value's metadata_writeable must match this value
- writeable: bool | None = None
- # [optional] the value's states map must include ANY of these key/value pairs
- any_available_states: set[tuple[int, str]] | None = None
- # [optional] the value's value must match this value
- value: Any | None = None
- # [optional] the value's metadata_stateful must match this value
- stateful: bool | None = None
+NEW_DISCOVERY_SCHEMAS: dict[Platform, list[NewZWaveDiscoverySchema]] = {
+ Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
+}
+SUPPORTED_PLATFORMS = tuple(NEW_DISCOVERY_SCHEMAS)
@dataclass
@@ -1316,7 +1228,7 @@ DISCOVERY_SCHEMAS = [
@callback
def async_discover_node_values(
node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]]
-) -> Generator[ZwaveDiscoveryInfo]:
+) -> Generator[ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo]:
"""Run discovery on ZWave node and return matching (primary) values."""
for value in node.values.values():
# We don't need to rediscover an already processed value_id
@@ -1327,9 +1239,19 @@ def async_discover_node_values(
@callback
def async_discover_single_value(
value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]]
-) -> Generator[ZwaveDiscoveryInfo]:
+) -> Generator[ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo]:
"""Run discovery on a single ZWave value and return matching schema info."""
- for schema in DISCOVERY_SCHEMAS:
+ # Temporary workaround for new schemas
+ schemas: tuple[ZWaveDiscoverySchema | NewZWaveDiscoverySchema, ...] = (
+ *(
+ new_schema
+ for _schemas in NEW_DISCOVERY_SCHEMAS.values()
+ for new_schema in _schemas
+ ),
+ *DISCOVERY_SCHEMAS,
+ )
+
+ for schema in schemas:
# abort if attribute(s) already discovered
if value.value_id in discovered_value_ids[device.id]:
continue
@@ -1458,18 +1380,38 @@ def async_discover_single_value(
)
# all checks passed, this value belongs to an entity
- yield ZwaveDiscoveryInfo(
- node=value.node,
- primary_value=value,
- assumed_state=schema.assumed_state,
- platform=schema.platform,
- platform_hint=schema.hint,
- platform_data_template=schema.data_template,
- platform_data=resolved_data,
- additional_value_ids_to_watch=additional_value_ids_to_watch,
- entity_registry_enabled_default=schema.entity_registry_enabled_default,
- entity_category=schema.entity_category,
- )
+
+ discovery_info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo
+
+ # Temporary workaround for new schemas
+ if isinstance(schema, NewZWaveDiscoverySchema):
+ discovery_info = NewZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=schema.assumed_state,
+ platform=schema.platform,
+ platform_data_template=schema.data_template,
+ platform_data=resolved_data,
+ additional_value_ids_to_watch=additional_value_ids_to_watch,
+ entity_class=schema.entity_class,
+ entity_description=schema.entity_description,
+ )
+
+ else:
+ discovery_info = ZwaveDiscoveryInfo(
+ node=value.node,
+ primary_value=value,
+ assumed_state=schema.assumed_state,
+ platform=schema.platform,
+ platform_hint=schema.hint,
+ platform_data_template=schema.data_template,
+ platform_data=resolved_data,
+ additional_value_ids_to_watch=additional_value_ids_to_watch,
+ entity_registry_enabled_default=schema.entity_registry_enabled_default,
+ entity_category=schema.entity_category,
+ )
+
+ yield discovery_info
# prevent re-discovery of the (primary) value if not allowed
if not schema.allow_multi:
@@ -1615,6 +1557,25 @@ def check_value(
)
):
return False
+ if (
+ schema.any_available_states_keys is not None
+ and value.metadata.states is not None
+ and not any(
+ str(key) in value.metadata.states
+ for key in schema.any_available_states_keys
+ )
+ ):
+ return False
+ # check available cc specific
+ if (
+ schema.any_available_cc_specific is not None
+ and value.metadata.cc_specific is not None
+ and not any(
+ key in value.metadata.cc_specific and value.metadata.cc_specific[key] == val
+ for key, val in schema.any_available_cc_specific
+ )
+ ):
+ return False
# check value
if schema.value is not None and value.value not in schema.value:
return False
diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py
index 731a786d226..8fbc5f35555 100644
--- a/homeassistant/components/zwave_js/discovery_data_template.py
+++ b/homeassistant/components/zwave_js/discovery_data_template.py
@@ -90,11 +90,9 @@ from zwave_js_server.const.command_class.multilevel_sensor import (
MultilevelSensorType,
)
from zwave_js_server.exceptions import UnknownValueData
-from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import (
ConfigurationValue as ZwaveConfigurationValue,
Value as ZwaveValue,
- get_value_id_str,
)
from zwave_js_server.util.command_class.energy_production import (
get_energy_production_parameter,
@@ -159,7 +157,7 @@ from .const import (
ENTITY_DESC_KEY_UV_INDEX,
ENTITY_DESC_KEY_VOLTAGE,
)
-from .helpers import ZwaveValueID
+from .models import BaseDiscoverySchemaDataTemplate, ZwaveValueID
ENERGY_PRODUCTION_DEVICE_CLASS_MAP: dict[str, list[EnergyProductionParameter]] = {
ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME: [EnergyProductionParameter.TOTAL_TIME],
@@ -264,49 +262,6 @@ MULTILEVEL_SENSOR_UNIT_MAP: dict[str, list[MultilevelSensorScaleType]] = {
_LOGGER = logging.getLogger(__name__)
-@dataclass
-class BaseDiscoverySchemaDataTemplate:
- """Base class for discovery schema data templates."""
-
- static_data: Any | None = None
-
- def resolve_data(self, value: ZwaveValue) -> Any:
- """Resolve helper class data for a discovered value.
-
- Can optionally be implemented by subclasses if input data needs to be
- transformed once discovered Value is available.
- """
- return {}
-
- def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue | None]:
- """Return list of all ZwaveValues resolved by helper that should be watched.
-
- Should be implemented by subclasses only if there are values to watch.
- """
- return []
-
- def value_ids_to_watch(self, resolved_data: Any) -> set[str]:
- """Return list of all Value IDs resolved by helper that should be watched.
-
- Not to be overwritten by subclasses.
- """
- return {val.value_id for val in self.values_to_watch(resolved_data) if val}
-
- @staticmethod
- def _get_value_from_id(
- node: ZwaveNode, value_id_obj: ZwaveValueID
- ) -> ZwaveValue | ZwaveConfigurationValue | None:
- """Get a ZwaveValue from a node using a ZwaveValueDict."""
- value_id = get_value_id_str(
- node,
- value_id_obj.command_class,
- value_id_obj.property_,
- endpoint=value_id_obj.endpoint,
- property_key=value_id_obj.property_key,
- )
- return node.values.get(value_id)
-
-
@dataclass
class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate):
"""Data template class for Z-Wave JS Climate entities with dynamic current temps."""
diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py
index 08a587d8d20..ab892565c0f 100644
--- a/homeassistant/components/zwave_js/entity.py
+++ b/homeassistant/components/zwave_js/entity.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
+from dataclasses import dataclass
from typing import Any
from zwave_js_server.exceptions import BaseZwaveJSServerError
@@ -18,16 +19,33 @@ from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.typing import UNDEFINED
from .const import DOMAIN, EVENT_VALUE_UPDATED, LOGGER
-from .discovery import ZwaveDiscoveryInfo
+from .discovery_data_template import BaseDiscoverySchemaDataTemplate
from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id
+from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo
EVENT_VALUE_REMOVED = "value removed"
+@dataclass(kw_only=True)
+class NewZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo):
+ """Info discovered from (primary) ZWave Value to create entity.
+
+ This is the new discovery info that will replace ZwaveDiscoveryInfo.
+ """
+
+ entity_class: type[ZWaveBaseEntity]
+ # the entity description to use
+ entity_description: EntityDescription
+ # helper data to use in platform setup
+ platform_data: Any = None
+ # data template to use in platform logic
+ platform_data_template: BaseDiscoverySchemaDataTemplate | None = None
+
+
class ZWaveBaseEntity(Entity):
"""Generic Entity Class for a Z-Wave Device."""
@@ -35,7 +53,10 @@ class ZWaveBaseEntity(Entity):
_attr_has_entity_name = True
def __init__(
- self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
+ self,
+ config_entry: ConfigEntry,
+ driver: Driver,
+ info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo,
) -> None:
"""Initialize a generic Z-Wave device entity."""
self.config_entry = config_entry
@@ -52,12 +73,14 @@ class ZWaveBaseEntity(Entity):
# Entity class attributes
self._attr_name = self.generate_name()
self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id)
- if self.info.entity_registry_enabled_default is False:
- self._attr_entity_registry_enabled_default = False
- if self.info.entity_category is not None:
- self._attr_entity_category = self.info.entity_category
- if self.info.assumed_state:
- self._attr_assumed_state = True
+ if isinstance(info, NewZwaveDiscoveryInfo):
+ self.entity_description = info.entity_description
+ else:
+ if (enabled_default := info.entity_registry_enabled_default) is False:
+ self._attr_entity_registry_enabled_default = enabled_default
+ if (entity_category := info.entity_category) is not None:
+ self._attr_entity_category = entity_category
+ self._attr_assumed_state = self.info.assumed_state
# device is precreated in main handler
self._attr_device_info = DeviceInfo(
identifiers={get_device_id(driver, self.info.node)},
diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py
index 17f4909662c..dc415c157b6 100644
--- a/homeassistant/components/zwave_js/helpers.py
+++ b/homeassistant/components/zwave_js/helpers.py
@@ -60,16 +60,6 @@ DRIVER_READY_EVENT_TIMEOUT = 60
SERVER_VERSION_TIMEOUT = 10
-@dataclass
-class ZwaveValueID:
- """Class to represent a value ID."""
-
- property_: str | int
- command_class: int
- endpoint: int | None = None
- property_key: str | int | None = None
-
-
@dataclass
class ZwaveValueMatcher:
"""Class to allow matching a Z-Wave Value."""
diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py
index 9b7c0222410..a5d54cf80c1 100644
--- a/homeassistant/components/zwave_js/light.py
+++ b/homeassistant/components/zwave_js/light.py
@@ -612,10 +612,7 @@ class ZwaveColorOnOffLight(ZwaveLight):
# If brightness gets set, preserve the color and mix it with the new brightness
if self.color_mode == ColorMode.HS:
scale = brightness / 255
- if (
- self._last_on_color is not None
- and None not in self._last_on_color.values()
- ):
+ if self._last_on_color is not None:
# Changed brightness from 0 to >0
old_brightness = max(self._last_on_color.values())
new_scale = brightness / old_brightness
@@ -634,8 +631,9 @@ class ZwaveColorOnOffLight(ZwaveLight):
elif current_brightness is not None:
scale = current_brightness / 255
- # Reset last color until turning off again
+ # Reset last color and brightness until turning off again
self._last_on_color = None
+ self._last_brightness = None
if new_colors is None:
new_colors = self._get_new_colors(
@@ -651,8 +649,10 @@ class ZwaveColorOnOffLight(ZwaveLight):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
- # Remember last color and brightness to restore it when turning on
- self._last_brightness = self.brightness
+ # Remember last color and brightness to restore it when turning on,
+ # only if we're sure the light is turned on to avoid overwriting good values
+ if self._last_brightness is None:
+ self._last_brightness = self.brightness
if self._current_color and isinstance(self._current_color.value, dict):
red = self._current_color.value.get(COLOR_SWITCH_COMBINED_RED)
green = self._current_color.value.get(COLOR_SWITCH_COMBINED_GREEN)
@@ -666,7 +666,8 @@ class ZwaveColorOnOffLight(ZwaveLight):
if blue is not None:
last_color[ColorComponent.BLUE] = blue
- if last_color:
+ # Only store the last color if we're aware of it, i.e. ignore off light
+ if last_color and max(last_color.values()) > 0:
self._last_on_color = last_color
if self._target_brightness:
diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py
index ac749cb516b..e4cd414a2bb 100644
--- a/homeassistant/components/zwave_js/migrate.py
+++ b/homeassistant/components/zwave_js/migrate.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
+from zwave_js_server.const import CommandClass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node
from zwave_js_server.model.value import Value as ZwaveValue
@@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
-from .discovery import ZwaveDiscoveryInfo
from .helpers import get_unique_id, get_valueless_base_unique_id
+from .models import PlatformZwaveDiscoveryInfo
_LOGGER = logging.getLogger(__name__)
@@ -140,7 +141,7 @@ def async_migrate_discovered_value(
registered_unique_ids: set[str],
device: dr.DeviceEntry,
driver: Driver,
- disc_info: ZwaveDiscoveryInfo,
+ disc_info: PlatformZwaveDiscoveryInfo,
) -> None:
"""Migrate unique ID for entity/entities tied to discovered value."""
@@ -162,7 +163,7 @@ def async_migrate_discovered_value(
if (
disc_info.platform == Platform.BINARY_SENSOR
- and disc_info.platform_hint == "notification"
+ and disc_info.primary_value.command_class == CommandClass.NOTIFICATION
):
for state_key in disc_info.primary_value.metadata.states:
# ignore idle key (0)
diff --git a/homeassistant/components/zwave_js/models.py b/homeassistant/components/zwave_js/models.py
index 63f77871c14..ba93be7a554 100644
--- a/homeassistant/components/zwave_js/models.py
+++ b/homeassistant/components/zwave_js/models.py
@@ -1,15 +1,27 @@
-"""Type definitions for Z-Wave JS integration."""
+"""Provide models for the Z-Wave integration."""
from __future__ import annotations
-from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from collections.abc import Iterable
+from dataclasses import asdict, dataclass, field
+from enum import StrEnum
+from typing import TYPE_CHECKING, Any
+from awesomeversion import AwesomeVersion
from zwave_js_server.const import LogLevel
+from zwave_js_server.model.node import Node as ZwaveNode
+from zwave_js_server.model.value import (
+ ConfigurationValue as ZwaveConfigurationValue,
+ Value as ZwaveValue,
+ get_value_id_str,
+)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.helpers.entity import EntityDescription
if TYPE_CHECKING:
+ from _typeshed import DataclassInstance
from zwave_js_server.client import Client as ZwaveClient
from . import DriverEvents
@@ -25,3 +37,213 @@ class ZwaveJSData:
type ZwaveJSConfigEntry = ConfigEntry[ZwaveJSData]
+
+
+@dataclass
+class ZwaveValueID:
+ """Class to represent a value ID."""
+
+ property_: str | int
+ command_class: int
+ endpoint: int | None = None
+ property_key: str | int | None = None
+
+
+class ValueType(StrEnum):
+ """Enum with all value types."""
+
+ ANY = "any"
+ BOOLEAN = "boolean"
+ NUMBER = "number"
+ STRING = "string"
+
+
+class DataclassMustHaveAtLeastOne:
+ """A dataclass that must have at least one input parameter that is not None."""
+
+ def __post_init__(self: DataclassInstance) -> None:
+ """Post dataclass initialization."""
+ if all(val is None for val in asdict(self).values()):
+ raise ValueError("At least one input parameter must not be None")
+
+
+@dataclass
+class FirmwareVersionRange(DataclassMustHaveAtLeastOne):
+ """Firmware version range dictionary."""
+
+ min: str | None = None
+ max: str | None = None
+ min_ver: AwesomeVersion | None = field(default=None, init=False)
+ max_ver: AwesomeVersion | None = field(default=None, init=False)
+
+ def __post_init__(self) -> None:
+ """Post dataclass initialization."""
+ super().__post_init__()
+ if self.min:
+ self.min_ver = AwesomeVersion(self.min)
+ if self.max:
+ self.max_ver = AwesomeVersion(self.max)
+
+
+@dataclass
+class PlatformZwaveDiscoveryInfo:
+ """Info discovered from (primary) ZWave Value to create entity."""
+
+ # node to which the value(s) belongs
+ node: ZwaveNode
+ # the value object itself for primary value
+ primary_value: ZwaveValue
+ # bool to specify whether state is assumed and events should be fired on value
+ # update
+ assumed_state: bool
+ # the home assistant platform for which an entity should be created
+ platform: Platform
+ # additional values that need to be watched by entity
+ additional_value_ids_to_watch: set[str]
+
+
+@dataclass
+class ZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo):
+ """Info discovered from (primary) ZWave Value to create entity."""
+
+ # helper data to use in platform setup
+ platform_data: Any = None
+ # data template to use in platform logic
+ platform_data_template: BaseDiscoverySchemaDataTemplate | None = None
+ # hint for the platform about this discovered entity
+ platform_hint: str | None = ""
+ # bool to specify whether entity should be enabled by default
+ entity_registry_enabled_default: bool = True
+ # the entity category for the discovered entity
+ entity_category: EntityCategory | None = None
+
+
+@dataclass
+class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne):
+ """Z-Wave Value discovery schema.
+
+ The Z-Wave Value must match these conditions.
+ Use the Z-Wave specifications to find out the values for these parameters:
+ https://github.com/zwave-js/specs/tree/master
+ """
+
+ # [optional] the value's command class must match ANY of these values
+ command_class: set[int] | None = None
+ # [optional] the value's endpoint must match ANY of these values
+ endpoint: set[int] | None = None
+ # [optional] the value's property must match ANY of these values
+ property: set[str | int] | None = None
+ # [optional] the value's property name must match ANY of these values
+ property_name: set[str] | None = None
+ # [optional] the value's property key must match ANY of these values
+ property_key: set[str | int | None] | None = None
+ # [optional] the value's property key must NOT match ANY of these values
+ not_property_key: set[str | int | None] | None = None
+ # [optional] the value's metadata_type must match ANY of these values
+ type: set[str] | None = None
+ # [optional] the value's metadata_readable must match this value
+ readable: bool | None = None
+ # [optional] the value's metadata_writeable must match this value
+ writeable: bool | None = None
+ # [optional] the value's states map must include ANY of these key/value pairs
+ any_available_states: set[tuple[int, str]] | None = None
+ # [optional] the value's states map must include ANY of these keys
+ any_available_states_keys: set[int] | None = None
+ # [optional] the value's cc specific map must include ANY of these key/value pairs
+ any_available_cc_specific: set[tuple[Any, Any]] | None = None
+ # [optional] the value's value must match this value
+ value: Any | None = None
+ # [optional] the value's metadata_stateful must match this value
+ stateful: bool | None = None
+
+
+@dataclass
+class NewZWaveDiscoverySchema:
+ """Z-Wave discovery schema.
+
+ The Z-Wave node and it's (primary) value for an entity must match these conditions.
+ Use the Z-Wave specifications to find out the values for these parameters:
+ https://github.com/zwave-js/node-zwave-js/tree/master/specs
+ """
+
+ # specify the hass platform for which this scheme applies (e.g. light, sensor)
+ platform: Platform
+ # platform-specific entity description
+ entity_description: EntityDescription
+ # entity class to use to instantiate the entity
+ entity_class: type
+ # primary value belonging to this discovery scheme
+ primary_value: ZWaveValueDiscoverySchema
+ # [optional] template to generate platform specific data to use in setup
+ data_template: BaseDiscoverySchemaDataTemplate | None = None
+ # [optional] the node's manufacturer_id must match ANY of these values
+ manufacturer_id: set[int] | None = None
+ # [optional] the node's product_id must match ANY of these values
+ product_id: set[int] | None = None
+ # [optional] the node's product_type must match ANY of these values
+ product_type: set[int] | None = None
+ # [optional] the node's firmware_version must be within this range
+ firmware_version_range: FirmwareVersionRange | None = None
+ # [optional] the node's firmware_version must match ANY of these values
+ firmware_version: set[str] | None = None
+ # [optional] the node's basic device class must match ANY of these values
+ device_class_basic: set[str | int] | None = None
+ # [optional] the node's generic device class must match ANY of these values
+ device_class_generic: set[str | int] | None = None
+ # [optional] the node's specific device class must match ANY of these values
+ device_class_specific: set[str | int] | None = None
+ # [optional] additional values that ALL need to be present
+ # on the node for this scheme to pass
+ required_values: list[ZWaveValueDiscoverySchema] | None = None
+ # [optional] additional values that MAY NOT be present
+ # on the node for this scheme to pass
+ absent_values: list[ZWaveValueDiscoverySchema] | None = None
+ # [optional] bool to specify if this primary value may be discovered
+ # by multiple platforms
+ allow_multi: bool = False
+ # [optional] bool to specify whether state is assumed
+ # and events should be fired on value update
+ assumed_state: bool = False
+
+
+@dataclass
+class BaseDiscoverySchemaDataTemplate:
+ """Base class for discovery schema data templates."""
+
+ static_data: Any | None = None
+
+ def resolve_data(self, value: ZwaveValue) -> Any:
+ """Resolve helper class data for a discovered value.
+
+ Can optionally be implemented by subclasses if input data needs to be
+ transformed once discovered Value is available.
+ """
+ return {}
+
+ def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue | None]:
+ """Return list of all ZwaveValues resolved by helper that should be watched.
+
+ Should be implemented by subclasses only if there are values to watch.
+ """
+ return []
+
+ def value_ids_to_watch(self, resolved_data: Any) -> set[str]:
+ """Return list of all Value IDs resolved by helper that should be watched.
+
+ Not to be overwritten by subclasses.
+ """
+ return {val.value_id for val in self.values_to_watch(resolved_data) if val}
+
+ @staticmethod
+ def _get_value_from_id(
+ node: ZwaveNode, value_id_obj: ZwaveValueID
+ ) -> ZwaveValue | ZwaveConfigurationValue | None:
+ """Get a ZwaveValue from a node using a ZwaveValueDict."""
+ value_id = get_value_id_str(
+ node,
+ value_id_obj.command_class,
+ value_id_obj.property_,
+ endpoint=value_id_obj.endpoint,
+ property_key=value_id_obj.property_key,
+ )
+ return node.values.get(value_id)
diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index fffcb2ca9dd..70ea973c3c8 100644
--- a/homeassistant/components/zwave_js/strings.json
+++ b/homeassistant/components/zwave_js/strings.json
@@ -14,14 +14,15 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.",
"different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.",
- "discovery_requires_supervisor": "Discovery requires the supervisor.",
+ "discovery_requires_supervisor": "Discovery requires the Home Assistant Supervisor.",
"migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the non-volatile memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.",
"migration_successful": "Migration successful.",
"not_zwave_device": "Discovered device is not a Z-Wave device.",
"not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"reset_failed": "Failed to reset adapter.",
- "usb_ports_failed": "Failed to get USB devices."
+ "usb_ports_failed": "Failed to get USB devices.",
+ "not_hassio": "ESPHome discovery requires Home Assistant to configure the Z-Wave add-on."
},
"error": {
"addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
@@ -140,6 +141,10 @@
"menu_options": {
"intent_migrate": "Migrate to a new adapter",
"intent_reconfigure": "Re-configure the current adapter"
+ },
+ "menu_option_descriptions": {
+ "intent_migrate": "This will move your Z-Wave network to a new adapter.",
+ "intent_reconfigure": "This will let you change the adapter configuration."
}
},
"instruct_unplug": {
diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py
index 150a32113e6..4273bf653c2 100644
--- a/homeassistant/components/zwave_js/triggers/event.py
+++ b/homeassistant/components/zwave_js/triggers/event.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import functools
+from typing import Any
from pydantic import ValidationError
import voluptuous as vol
@@ -15,12 +16,20 @@ from homeassistant.const import (
ATTR_CONFIG_ENTRY_ID,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
+ CONF_OPTIONS,
CONF_PLATFORM,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInfo
+from homeassistant.helpers.trigger import (
+ Trigger,
+ TriggerActionType,
+ TriggerConfig,
+ TriggerData,
+ TriggerInfo,
+)
from homeassistant.helpers.typing import ConfigType
from ..const import (
@@ -90,86 +99,122 @@ def validate_event_data(obj: dict) -> dict:
return obj
-TRIGGER_SCHEMA = vol.All(
- cv.TRIGGER_BASE_SCHEMA.extend(
- {
- vol.Required(CONF_PLATFORM): PLATFORM_TYPE,
- vol.Optional(ATTR_CONFIG_ENTRY_ID): str,
- vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_EVENT_SOURCE): vol.In(["controller", "driver", "node"]),
- vol.Required(ATTR_EVENT): cv.string,
- vol.Optional(ATTR_EVENT_DATA): dict,
- vol.Optional(ATTR_PARTIAL_DICT_MATCH, default=False): bool,
- },
- ),
- validate_event_name,
- validate_event_data,
- vol.Any(
- validate_non_node_event_source,
- cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
- ),
+_OPTIONS_SCHEMA_DICT = {
+ vol.Optional(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_EVENT_SOURCE): vol.In(["controller", "driver", "node"]),
+ vol.Required(ATTR_EVENT): cv.string,
+ vol.Optional(ATTR_EVENT_DATA): dict,
+ vol.Optional(ATTR_PARTIAL_DICT_MATCH, default=False): bool,
+}
+
+_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OPTIONS): vol.All(
+ _OPTIONS_SCHEMA_DICT,
+ validate_event_name,
+ validate_event_data,
+ vol.Any(
+ validate_non_node_event_source,
+ cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
+ ),
+ )
+ }
)
-async def async_validate_trigger_config(
- hass: HomeAssistant, config: ConfigType
-) -> ConfigType:
- """Validate config."""
- config = TRIGGER_SCHEMA(config)
+class EventTrigger(Trigger):
+ """Z-Wave JS event trigger."""
- if ATTR_CONFIG_ENTRY_ID in config:
- entry_id = config[ATTR_CONFIG_ENTRY_ID]
- if hass.config_entries.async_get_entry(entry_id) is None:
- raise vol.Invalid(f"Config entry '{entry_id}' not found")
+ _hass: HomeAssistant
+ _options: dict[str, Any]
+
+ _event_source: str
+ _event_name: str
+ _event_data_filter: dict
+ _job: HassJob
+ _trigger_data: TriggerData
+ _unsubs: list[Callable]
+
+ _platform_type = PLATFORM_TYPE
+
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config."""
+ complete_config = move_top_level_schema_fields_to_options(
+ complete_config, _OPTIONS_SCHEMA_DICT
+ )
+ return await super().async_validate_complete_config(hass, complete_config)
+
+ @classmethod
+ async def async_validate_config(
+ cls, hass: HomeAssistant, config: ConfigType
+ ) -> ConfigType:
+ """Validate config."""
+ config = _CONFIG_SCHEMA(config)
+ options = config[CONF_OPTIONS]
+
+ if ATTR_CONFIG_ENTRY_ID in options:
+ entry_id = options[ATTR_CONFIG_ENTRY_ID]
+ if hass.config_entries.async_get_entry(entry_id) is None:
+ raise vol.Invalid(f"Config entry '{entry_id}' not found")
+
+ if async_bypass_dynamic_config_validation(hass, options):
+ return config
+
+ if options[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets(
+ hass, options
+ ):
+ raise vol.Invalid(
+ f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
+ )
- if async_bypass_dynamic_config_validation(hass, config):
return config
- if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets(
- hass, config
- ):
- raise vol.Invalid(
- f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
- )
+ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
+ """Initialize trigger."""
+ self._hass = hass
+ assert config.options is not None
+ self._options = config.options
- return config
+ async def async_attach(
+ self,
+ action: TriggerActionType,
+ trigger_info: TriggerInfo,
+ ) -> CALLBACK_TYPE:
+ """Attach a trigger."""
+ dev_reg = dr.async_get(self._hass)
+ options = self._options
+ if options[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets(
+ self._hass, options, dev_reg=dev_reg
+ ):
+ raise ValueError(
+ f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
+ )
+ self._event_source = options[ATTR_EVENT_SOURCE]
+ self._event_name = options[ATTR_EVENT]
+ self._event_data_filter = options.get(ATTR_EVENT_DATA, {})
+ self._job = HassJob(action)
+ self._trigger_data = trigger_info["trigger_data"]
+ self._unsubs: list[Callable] = []
-async def async_attach_trigger(
- hass: HomeAssistant,
- config: ConfigType,
- action: TriggerActionType,
- trigger_info: TriggerInfo,
- *,
- platform_type: str = PLATFORM_TYPE,
-) -> CALLBACK_TYPE:
- """Listen for state changes based on configuration."""
- dev_reg = dr.async_get(hass)
- if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets(
- hass, config, dev_reg=dev_reg
- ):
- raise ValueError(
- f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
- )
-
- event_source = config[ATTR_EVENT_SOURCE]
- event_name = config[ATTR_EVENT]
- event_data_filter = config.get(ATTR_EVENT_DATA, {})
-
- unsubs: list[Callable] = []
- job = HassJob(action)
-
- trigger_data = trigger_info["trigger_data"]
+ self._create_zwave_listeners()
+ return self._async_remove
@callback
- def async_on_event(event_data: dict, device: dr.DeviceEntry | None = None) -> None:
+ def _async_on_event(
+ self, event_data: dict, device: dr.DeviceEntry | None = None
+ ) -> None:
"""Handle event."""
- for key, val in event_data_filter.items():
+ for key, val in self._event_data_filter.items():
if key not in event_data:
return
if (
- config[ATTR_PARTIAL_DICT_MATCH]
+ self._options[ATTR_PARTIAL_DICT_MATCH]
and isinstance(event_data[key], dict)
and isinstance(val, dict)
):
@@ -181,14 +226,16 @@ async def async_attach_trigger(
return
payload = {
- **trigger_data,
- CONF_PLATFORM: platform_type,
- ATTR_EVENT_SOURCE: event_source,
- ATTR_EVENT: event_name,
+ **self._trigger_data,
+ CONF_PLATFORM: self._platform_type,
+ ATTR_EVENT_SOURCE: self._event_source,
+ ATTR_EVENT: self._event_name,
ATTR_EVENT_DATA: event_data,
}
- primary_desc = f"Z-Wave JS '{event_source}' event '{event_name}' was emitted"
+ primary_desc = (
+ f"Z-Wave JS '{self._event_source}' event '{self._event_name}' was emitted"
+ )
if device:
device_name = device.name_by_user or device.name
@@ -204,34 +251,41 @@ async def async_attach_trigger(
f"{payload['description']} with event data: {event_data}"
)
- hass.async_run_hass_job(job, {"trigger": payload})
+ self._hass.async_run_hass_job(self._job, {"trigger": payload})
@callback
- def async_remove() -> None:
+ def _async_remove(self) -> None:
"""Remove state listeners async."""
- for unsub in unsubs:
+ for unsub in self._unsubs:
unsub()
- unsubs.clear()
+ self._unsubs.clear()
@callback
- def _create_zwave_listeners() -> None:
+ def _create_zwave_listeners(self) -> None:
"""Create Z-Wave JS listeners."""
- async_remove()
+ self._async_remove()
# Nodes list can come from different drivers and we will need to listen to
# server connections for all of them.
drivers: set[Driver] = set()
- if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)):
- entry_id = config[ATTR_CONFIG_ENTRY_ID]
- entry = hass.config_entries.async_get_entry(entry_id)
+ dev_reg = dr.async_get(self._hass)
+ if not (
+ nodes := async_get_nodes_from_targets(
+ self._hass, self._options, dev_reg=dev_reg
+ )
+ ):
+ entry_id = self._options[ATTR_CONFIG_ENTRY_ID]
+ entry = self._hass.config_entries.async_get_entry(entry_id)
assert entry
client = entry.runtime_data.client
driver = client.driver
assert driver
drivers.add(driver)
- if event_source == "controller":
- unsubs.append(driver.controller.on(event_name, async_on_event))
+ if self._event_source == "controller":
+ self._unsubs.append(
+ driver.controller.on(self._event_name, self._async_on_event)
+ )
else:
- unsubs.append(driver.on(event_name, async_on_event))
+ self._unsubs.append(driver.on(self._event_name, self._async_on_event))
for node in nodes:
driver = node.client.driver
@@ -241,44 +295,17 @@ async def async_attach_trigger(
device = dev_reg.async_get_device(identifiers={device_identifier})
assert device
# We need to store the device for the callback
- unsubs.append(
- node.on(event_name, functools.partial(async_on_event, device=device))
+ self._unsubs.append(
+ node.on(
+ self._event_name,
+ functools.partial(self._async_on_event, device=device),
+ )
)
- unsubs.extend(
+ self._unsubs.extend(
async_dispatcher_connect(
- hass,
+ self._hass,
f"{DOMAIN}_{driver.controller.home_id}_connected_to_server",
- _create_zwave_listeners,
+ self._create_zwave_listeners,
)
for driver in drivers
)
-
- _create_zwave_listeners()
-
- return async_remove
-
-
-class EventTrigger(Trigger):
- """Z-Wave JS event trigger."""
-
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize trigger."""
- self._config = config
- self._hass = hass
-
- @classmethod
- async def async_validate_config(
- cls, hass: HomeAssistant, config: ConfigType
- ) -> ConfigType:
- """Validate config."""
- return await async_validate_trigger_config(hass, config)
-
- async def async_attach(
- self,
- action: TriggerActionType,
- trigger_info: TriggerInfo,
- ) -> CALLBACK_TYPE:
- """Attach a trigger."""
- return await async_attach_trigger(
- self._hass, self._config, action, trigger_info
- )
diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py
index f46592769cb..7ea565299d6 100644
--- a/homeassistant/components/zwave_js/triggers/value_updated.py
+++ b/homeassistant/components/zwave_js/triggers/value_updated.py
@@ -4,17 +4,30 @@ from __future__ import annotations
from collections.abc import Callable
import functools
+from typing import Any
import voluptuous as vol
from zwave_js_server.const import CommandClass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value, get_value_id_str
-from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL
+from homeassistant.const import (
+ ATTR_DEVICE_ID,
+ ATTR_ENTITY_ID,
+ CONF_OPTIONS,
+ CONF_PLATFORM,
+ MATCH_ALL,
+)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInfo
+from homeassistant.helpers.trigger import (
+ Trigger,
+ TriggerActionType,
+ TriggerConfig,
+ TriggerInfo,
+)
from homeassistant.helpers.typing import ConfigType
from ..config_validation import VALUE_SCHEMA
@@ -46,27 +59,26 @@ PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}"
ATTR_FROM = "from"
ATTR_TO = "to"
-TRIGGER_SCHEMA = vol.All(
- cv.TRIGGER_BASE_SCHEMA.extend(
- {
- vol.Required(CONF_PLATFORM): PLATFORM_TYPE,
- vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_COMMAND_CLASS): vol.In(
- {cc.value: cc.name for cc in CommandClass}
- ),
- vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
- vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
- vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
- vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any(
- VALUE_SCHEMA, [VALUE_SCHEMA]
- ),
- vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any(
- VALUE_SCHEMA, [VALUE_SCHEMA]
- ),
- },
+_OPTIONS_SCHEMA_DICT = {
+ vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_COMMAND_CLASS): vol.In(
+ {cc.value: cc.name for cc in CommandClass}
),
- cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID),
+ vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
+ vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
+ vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
+ vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any(VALUE_SCHEMA, [VALUE_SCHEMA]),
+ vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any(VALUE_SCHEMA, [VALUE_SCHEMA]),
+}
+
+_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_OPTIONS): vol.All(
+ _OPTIONS_SCHEMA_DICT,
+ cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID),
+ ),
+ },
)
@@ -74,12 +86,13 @@ async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
- config = TRIGGER_SCHEMA(config)
+ config = _CONFIG_SCHEMA(config)
+ options = config[CONF_OPTIONS]
- if async_bypass_dynamic_config_validation(hass, config):
+ if async_bypass_dynamic_config_validation(hass, options):
return config
- if not async_get_nodes_from_targets(hass, config):
+ if not async_get_nodes_from_targets(hass, options):
raise vol.Invalid(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
@@ -88,7 +101,7 @@ async def async_validate_trigger_config(
async def async_attach_trigger(
hass: HomeAssistant,
- config: ConfigType,
+ options: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
@@ -96,17 +109,17 @@ async def async_attach_trigger(
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
dev_reg = dr.async_get(hass)
- if not async_get_nodes_from_targets(hass, config, dev_reg=dev_reg):
+ if not async_get_nodes_from_targets(hass, options, dev_reg=dev_reg):
raise ValueError(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
- from_value = config[ATTR_FROM]
- to_value = config[ATTR_TO]
- command_class = config[ATTR_COMMAND_CLASS]
- property_ = config[ATTR_PROPERTY]
- endpoint = config.get(ATTR_ENDPOINT)
- property_key = config.get(ATTR_PROPERTY_KEY)
+ from_value = options[ATTR_FROM]
+ to_value = options[ATTR_TO]
+ command_class = options[ATTR_COMMAND_CLASS]
+ property_ = options[ATTR_PROPERTY]
+ endpoint = options.get(ATTR_ENDPOINT)
+ property_key = options.get(ATTR_PROPERTY_KEY)
unsubs: list[Callable] = []
job = HassJob(action)
@@ -174,7 +187,7 @@ async def async_attach_trigger(
# Nodes list can come from different drivers and we will need to listen to
# server connections for all of them.
drivers: set[Driver] = set()
- for node in async_get_nodes_from_targets(hass, config, dev_reg=dev_reg):
+ for node in async_get_nodes_from_targets(hass, options, dev_reg=dev_reg):
driver = node.client.driver
assert driver is not None # The node comes from the driver.
drivers.add(driver)
@@ -210,10 +223,18 @@ async def async_attach_trigger(
class ValueUpdatedTrigger(Trigger):
"""Z-Wave JS value updated trigger."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize trigger."""
- self._config = config
- self._hass = hass
+ _hass: HomeAssistant
+ _options: dict[str, Any]
+
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config."""
+ complete_config = move_top_level_schema_fields_to_options(
+ complete_config, _OPTIONS_SCHEMA_DICT
+ )
+ return await super().async_validate_complete_config(hass, complete_config)
@classmethod
async def async_validate_config(
@@ -222,6 +243,12 @@ class ValueUpdatedTrigger(Trigger):
"""Validate config."""
return await async_validate_trigger_config(hass, config)
+ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
+ """Initialize trigger."""
+ self._hass = hass
+ assert config.options is not None
+ self._options = config.options
+
async def async_attach(
self,
action: TriggerActionType,
@@ -229,5 +256,5 @@ class ValueUpdatedTrigger(Trigger):
) -> CALLBACK_TYPE:
"""Attach a trigger."""
return await async_attach_trigger(
- self._hass, self._config, action, trigger_info
+ self._hass, self._options, action, trigger_info
)
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index f5ccf9c3143..9612868383e 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__)
SOURCE_BLUETOOTH = "bluetooth"
SOURCE_DHCP = "dhcp"
SOURCE_DISCOVERY = "discovery"
+SOURCE_ESPHOME = "esphome"
SOURCE_HARDWARE = "hardware"
SOURCE_HASSIO = "hassio"
SOURCE_HOMEKIT = "homekit"
@@ -299,6 +300,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
"""Typed result dict for config flow."""
# Extra keys, only present if type is CREATE_ENTRY
+ next_flow: tuple[FlowType, str] # (flow type, flow id)
minor_version: int
options: Mapping[str, Any]
result: ConfigEntry
@@ -306,6 +308,14 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
version: int
+class FlowType(StrEnum):
+ """Flow type."""
+
+ CONFIG_FLOW = "config_flow"
+ # Add other flow types here as needed in the future,
+ # if we want to support them in the `next_flow` parameter.
+
+
def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None:
"""Validate config entry item."""
@@ -1178,7 +1188,13 @@ class ConfigEntry[_DataT = Any]:
@callback
def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE:
- """Add a function to call when a config entry changes its state."""
+ """Add a function to call when a config entry changes its state.
+
+ Note: async_on_unload listeners are called before the state is changed to
+ NOT_LOADED when unloading a config entry. This means the passed function
+ will not be called after a config entry has been unloaded, the last call
+ will be after the state is changed to UNLOAD_IN_PROGRESS.
+ """
if self._on_state_change is None:
self._on_state_change = []
self._on_state_change.append(func)
@@ -2321,8 +2337,9 @@ class ConfigEntries:
entry: ConfigEntry,
*,
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
- discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
- | UndefinedType = UNDEFINED,
+ discovery_keys: (
+ MappingProxyType[str, tuple[DiscoveryKey, ...]] | UndefinedType
+ ) = UNDEFINED,
minor_version: int | UndefinedType = UNDEFINED,
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
@@ -2358,8 +2375,9 @@ class ConfigEntries:
entry: ConfigEntry,
*,
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
- discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
- | UndefinedType = UNDEFINED,
+ discovery_keys: (
+ MappingProxyType[str, tuple[DiscoveryKey, ...]] | UndefinedType
+ ) = UNDEFINED,
minor_version: int | UndefinedType = UNDEFINED,
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
@@ -2713,7 +2731,10 @@ class ConfigEntries:
continue
issues.add(issue.issue_id)
- for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001
+ for (
+ domain,
+ unique_ids,
+ ) in self._entries._domain_unique_id_index.items(): # noqa: SLF001
for unique_id, entries in unique_ids.items():
# We might mutate the list of entries, so we need a copy to not mess up
# the index
@@ -2780,7 +2801,9 @@ def _async_abort_entries_match(
Requires `already_configured` in strings.json in user visible flows.
"""
if match_dict is None:
- match_dict = {} # Match any entry
+ if other_entries:
+ raise data_entry_flow.AbortFlow("already_configured") # Match any entry
+ return
for entry in other_entries:
options_items = entry.options.items()
data_items = entry.data.items()
@@ -3130,6 +3153,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
data: Mapping[str, Any],
description: str | None = None,
description_placeholders: Mapping[str, str] | None = None,
+ next_flow: tuple[FlowType, str] | None = None,
options: Mapping[str, Any] | None = None,
subentries: Iterable[ConfigSubentryData] | None = None,
) -> ConfigFlowResult:
@@ -3150,6 +3174,13 @@ class ConfigFlow(ConfigEntryBaseFlow):
)
result["minor_version"] = self.MINOR_VERSION
+ if next_flow is not None:
+ flow_type, flow_id = next_flow
+ if flow_type != FlowType.CONFIG_FLOW:
+ raise HomeAssistantError("Invalid next_flow type")
+ # Raises UnknownFlow if the flow does not exist.
+ self.hass.config_entries.flow.async_get(flow_id)
+ result["next_flow"] = next_flow
result["options"] = options or {}
result["subentries"] = subentries or ()
result["version"] = self.VERSION
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 16d361a7957..4ae1a73df6b 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -6,6 +6,7 @@ from enum import StrEnum
from functools import partial
from typing import TYPE_CHECKING, Final
+from .generated.entity_platforms import EntityPlatforms
from .helpers.deprecation import (
DeprecatedConstant,
DeprecatedConstantEnum,
@@ -24,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
-MINOR_VERSION: Final = 9
+MINOR_VERSION: Final = 11
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
@@ -36,54 +37,8 @@ REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""
# Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}"
-
-class Platform(StrEnum):
- """Available entity platforms."""
-
- AI_TASK = "ai_task"
- AIR_QUALITY = "air_quality"
- ALARM_CONTROL_PANEL = "alarm_control_panel"
- ASSIST_SATELLITE = "assist_satellite"
- BINARY_SENSOR = "binary_sensor"
- BUTTON = "button"
- CALENDAR = "calendar"
- CAMERA = "camera"
- CLIMATE = "climate"
- CONVERSATION = "conversation"
- COVER = "cover"
- DATE = "date"
- DATETIME = "datetime"
- DEVICE_TRACKER = "device_tracker"
- EVENT = "event"
- FAN = "fan"
- GEO_LOCATION = "geo_location"
- HUMIDIFIER = "humidifier"
- IMAGE = "image"
- IMAGE_PROCESSING = "image_processing"
- LAWN_MOWER = "lawn_mower"
- LIGHT = "light"
- LOCK = "lock"
- MEDIA_PLAYER = "media_player"
- NOTIFY = "notify"
- NUMBER = "number"
- REMOTE = "remote"
- SCENE = "scene"
- SELECT = "select"
- SENSOR = "sensor"
- SIREN = "siren"
- STT = "stt"
- SWITCH = "switch"
- TEXT = "text"
- TIME = "time"
- TODO = "todo"
- TTS = "tts"
- UPDATE = "update"
- VACUUM = "vacuum"
- VALVE = "valve"
- WAKE_WORD = "wake_word"
- WATER_HEATER = "water_heater"
- WEATHER = "weather"
-
+# Explicit reexport to allow other modules to import Platform directly from const
+Platform = EntityPlatforms
BASE_PLATFORMS: Final = {platform.value for platform in Platform}
@@ -231,6 +186,7 @@ CONF_MONITORED_VARIABLES: Final = "monitored_variables"
CONF_NAME: Final = "name"
CONF_OFFSET: Final = "offset"
CONF_OPTIMISTIC: Final = "optimistic"
+CONF_OPTIONS: Final = "options"
CONF_PACKAGES: Final = "packages"
CONF_PARALLEL: Final = "parallel"
CONF_PARAMS: Final = "params"
@@ -359,34 +315,6 @@ STATE_UNAVAILABLE: Final = "unavailable"
STATE_OK: Final = "ok"
STATE_PROBLEM: Final = "problem"
-# #### LOCK STATES ####
-# STATE_* below are deprecated as of 2024.10
-# use the LockState enum instead.
-_DEPRECATED_STATE_LOCKED: Final = DeprecatedConstant(
- "locked",
- "LockState.LOCKED",
- "2025.10",
-)
-_DEPRECATED_STATE_UNLOCKED: Final = DeprecatedConstant(
- "unlocked",
- "LockState.UNLOCKED",
- "2025.10",
-)
-_DEPRECATED_STATE_LOCKING: Final = DeprecatedConstant(
- "locking",
- "LockState.LOCKING",
- "2025.10",
-)
-_DEPRECATED_STATE_UNLOCKING: Final = DeprecatedConstant(
- "unlocking",
- "LockState.UNLOCKING",
- "2025.10",
-)
-_DEPRECATED_STATE_JAMMED: Final = DeprecatedConstant(
- "jammed",
- "LockState.JAMMED",
- "2025.10",
-)
# #### ALARM CONTROL PANEL STATES ####
# STATE_ALARM_* below are deprecated as of 2024.11
@@ -590,6 +518,7 @@ class UnitOfApparentPower(StrEnum):
MILLIVOLT_AMPERE = "mVA"
VOLT_AMPERE = "VA"
+ KILO_VOLT_AMPERE = "kVA"
# Power units
@@ -748,6 +677,7 @@ class UnitOfPressure(StrEnum):
MBAR = "mbar"
MMHG = "mmHg"
INHG = "inHg"
+ INH2O = "inH₂O"
PSI = "psi"
@@ -765,6 +695,7 @@ class UnitOfVolume(StrEnum):
CUBIC_FEET = "ft³"
CENTUM_CUBIC_FEET = "CCF"
+ MILLE_CUBIC_FEET = "MCF"
CUBIC_METERS = "m³"
LITERS = "L"
MILLILITERS = "mL"
@@ -940,6 +871,7 @@ class UnitOfSpeed(StrEnum):
BEAUFORT = "Beaufort"
FEET_PER_SECOND = "ft/s"
INCHES_PER_SECOND = "in/s"
+ METERS_PER_MINUTE = "m/min"
METERS_PER_SECOND = "m/s"
KILOMETERS_PER_HOUR = "km/h"
KNOTS = "kn"
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 5023d291ad5..d9e58a8dda8 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -5,14 +5,15 @@ from __future__ import annotations
import abc
import asyncio
from collections import defaultdict
-from collections.abc import Callable, Container, Hashable, Iterable, Mapping
+from collections.abc import Callable, Container, Coroutine, 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, Generic, Required, TypedDict, TypeVar, cast
+from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast
import voluptuous as vol
@@ -142,6 +143,7 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False):
progress_task: asyncio.Task[Any] | None
reason: str
required: bool
+ sort: bool
step_id: str
title: str
translation_domain: str
@@ -149,6 +151,15 @@ 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,
@@ -638,6 +649,12 @@ 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] = {
+ "tasks": {},
+ "abort_reason": "",
+ "abort_description_placeholders": MappingProxyType({}),
+ "next_step_result": None,
+ }
@property
def source(self) -> str | None:
@@ -760,6 +777,37 @@ 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."""
+ return self.async_abort(
+ reason=self._progress_step_data["abort_reason"],
+ description_placeholders=self._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.
+ """
+ if self._progress_step_data["next_step_result"] is None:
+ return self.async_abort(
+ reason=self._progress_step_data["abort_reason"],
+ description_placeholders=self._progress_step_data[
+ "abort_description_placeholders"
+ ],
+ )
+ return self._progress_step_data["next_step_result"]
+
@callback
def async_external_step(
self,
@@ -854,6 +902,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
*,
step_id: str | None = None,
menu_options: Container[str],
+ sort: bool = False,
description_placeholders: Mapping[str, str] | None = None,
) -> _FlowResultT:
"""Show a navigation menu to the user.
@@ -868,6 +917,8 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
menu_options=menu_options,
description_placeholders=description_placeholders,
)
+ if sort:
+ flow_result["sort"] = sort
if step_id is not None:
flow_result["step_id"] = step_id
return flow_result
@@ -926,3 +977,90 @@ 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_", "")
+
+ # Check if we have a progress task running
+ progress_task = self._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}",
+ )
+ self._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:
+ self._progress_step_data["next_step_result"] = await progress_task
+ except AbortFlow as err:
+ self._progress_step_data["abort_reason"] = err.reason
+ self._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
+ self._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/application_credentials.py b/homeassistant/generated/application_credentials.py
index f3b83e39df9..38cd82a39d7 100644
--- a/homeassistant/generated/application_credentials.py
+++ b/homeassistant/generated/application_credentials.py
@@ -4,7 +4,9 @@ To update, run python3 -m script.hassfest
"""
APPLICATION_CREDENTIALS = [
+ "aladdin_connect",
"august",
+ "ekeybionyx",
"electric_kiwi",
"fitbit",
"geocaching",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index fba16901e14..9394d57beb9 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -47,6 +47,7 @@ FLOWS = {
"airvisual_pro",
"airzone",
"airzone_cloud",
+ "aladdin_connect",
"alarmdecoder",
"alexa_devices",
"altruist",
@@ -87,6 +88,7 @@ FLOWS = {
"baf",
"balboa",
"bang_olufsen",
+ "bayesian",
"blebox",
"blink",
"blue_current",
@@ -119,11 +121,13 @@ FLOWS = {
"coinbase",
"color_extractor",
"comelit",
+ "compit",
"control4",
"cookidoo",
"coolmaster",
"cpuspeed",
"crownstone",
+ "cync",
"daikin",
"datadog",
"deako",
@@ -147,6 +151,7 @@ FLOWS = {
"downloader",
"dremel_3d_printer",
"drop_connect",
+ "droplet",
"dsmr",
"dsmr_reader",
"duke_energy",
@@ -164,6 +169,7 @@ FLOWS = {
"edl21",
"efergy",
"eheimdigital",
+ "ekeybionyx",
"electrasmart",
"electric_kiwi",
"elevenlabs",
@@ -195,6 +201,7 @@ FLOWS = {
"fibaro",
"file",
"filesize",
+ "firefly_iii",
"fireservicerota",
"fitbit",
"fivem",
@@ -264,6 +271,7 @@ FLOWS = {
"hlk_sw16",
"holiday",
"home_connect",
+ "homeassistant_connect_zbt2",
"homeassistant_sky_connect",
"homee",
"homekit",
@@ -306,6 +314,7 @@ FLOWS = {
"ipma",
"ipp",
"iqvia",
+ "irm_kmi",
"iron_os",
"iskra",
"islamic_prayer_times",
@@ -347,6 +356,7 @@ FLOWS = {
"lg_netcast",
"lg_soundbar",
"lg_thinq",
+ "libre_hardware_monitor",
"lidarr",
"lifx",
"linkplay",
@@ -361,6 +371,7 @@ FLOWS = {
"lookin",
"loqed",
"luftdaten",
+ "lunatone",
"lupusec",
"lutron",
"lutron_caseta",
@@ -380,6 +391,7 @@ FLOWS = {
"met",
"met_eireann",
"meteo_france",
+ "meteo_lt",
"meteoclimatic",
"metoffice",
"microbees",
@@ -414,6 +426,7 @@ FLOWS = {
"nanoleaf",
"nasweb",
"neato",
+ "nederlandse_spoorwegen",
"nest",
"netatmo",
"netgear",
@@ -487,9 +500,10 @@ FLOWS = {
"playstation_network",
"plex",
"plugwise",
- "plum_lightpad",
"point",
+ "pooldose",
"poolsense",
+ "portainer",
"powerfox",
"powerwall",
"private_ble_device",
@@ -542,6 +556,7 @@ FLOWS = {
"romy",
"roomba",
"roon",
+ "route_b_smart_meter",
"rova",
"rpi_power",
"ruckus_unleashed",
@@ -552,6 +567,7 @@ FLOWS = {
"sabnzbd",
"samsungtv",
"sanix",
+ "satel_integra",
"schlage",
"scrape",
"screenlogic",
@@ -567,6 +583,7 @@ FLOWS = {
"senz",
"seventeentrack",
"sfr_box",
+ "sftp_storage",
"sharkiq",
"shelly",
"shopping_list",
@@ -698,6 +715,7 @@ FLOWS = {
"version",
"vesync",
"vicare",
+ "victron_remote_monitoring",
"vilfo",
"vizio",
"vlc_telnet",
@@ -706,7 +724,6 @@ FLOWS = {
"volumio",
"volvo",
"volvooncall",
- "vulcan",
"wake_on_lan",
"wallbox",
"waqi",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index 3c1d929b1d8..e744f42b541 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -26,6 +26,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "airzone",
"macaddress": "E84F25*",
},
+ {
+ "domain": "aladdin_connect",
+ "hostname": "gdocntl-*",
+ },
{
"domain": "august",
"hostname": "connect",
@@ -559,6 +563,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "playstation_network",
"macaddress": "84E657*",
},
+ {
+ "domain": "pooldose",
+ "hostname": "kommspot",
+ },
{
"domain": "powerwall",
"hostname": "1118431-*",
diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py
new file mode 100644
index 00000000000..7010ffc9be7
--- /dev/null
+++ b/homeassistant/generated/entity_platforms.py
@@ -0,0 +1,54 @@
+"""Automatically generated file.
+
+To update, run python3 -m script.hassfest
+"""
+
+from enum import StrEnum
+
+
+class EntityPlatforms(StrEnum):
+ """Available entity platforms."""
+
+ AI_TASK = "ai_task"
+ AIR_QUALITY = "air_quality"
+ ALARM_CONTROL_PANEL = "alarm_control_panel"
+ ASSIST_SATELLITE = "assist_satellite"
+ BINARY_SENSOR = "binary_sensor"
+ BUTTON = "button"
+ CALENDAR = "calendar"
+ CAMERA = "camera"
+ CLIMATE = "climate"
+ CONVERSATION = "conversation"
+ COVER = "cover"
+ DATE = "date"
+ DATETIME = "datetime"
+ DEVICE_TRACKER = "device_tracker"
+ EVENT = "event"
+ FAN = "fan"
+ GEO_LOCATION = "geo_location"
+ HUMIDIFIER = "humidifier"
+ IMAGE = "image"
+ IMAGE_PROCESSING = "image_processing"
+ LAWN_MOWER = "lawn_mower"
+ LIGHT = "light"
+ LOCK = "lock"
+ MEDIA_PLAYER = "media_player"
+ NOTIFY = "notify"
+ NUMBER = "number"
+ REMOTE = "remote"
+ SCENE = "scene"
+ SELECT = "select"
+ SENSOR = "sensor"
+ SIREN = "siren"
+ STT = "stt"
+ SWITCH = "switch"
+ TEXT = "text"
+ TIME = "time"
+ TODO = "todo"
+ TTS = "tts"
+ UPDATE = "update"
+ VACUUM = "vacuum"
+ VALVE = "valve"
+ WAKE_WORD = "wake_word"
+ WATER_HEATER = "water_heater"
+ WEATHER = "weather"
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 3e2a7ce75a9..32e48d4aac6 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -22,8 +22,7 @@
"name": "AccuWeather",
"integration_type": "service",
"config_flow": true,
- "iot_class": "cloud_polling",
- "single_config_entry": true
+ "iot_class": "cloud_polling"
},
"acer_projector": {
"name": "Acer Projector",
@@ -187,6 +186,12 @@
}
}
},
+ "aladdin_connect": {
+ "name": "Aladdin Connect",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"alarmdecoder": {
"name": "AlarmDecoder",
"integration_type": "device",
@@ -437,11 +442,6 @@
"config_flow": false,
"iot_class": "cloud_push"
},
- "aps": {
- "name": "Arizona Public Service (APS)",
- "integration_type": "virtual",
- "supported_by": "opower"
- },
"apsystems": {
"name": "APsystems",
"integration_type": "device",
@@ -670,6 +670,12 @@
"integration_type": "virtual",
"supported_by": "whirlpool"
},
+ "bayesian": {
+ "name": "Bayesian",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "calculated"
+ },
"bbox": {
"name": "Bbox",
"integration_type": "hub",
@@ -1083,6 +1089,12 @@
"config_flow": false,
"iot_class": "calculated"
},
+ "compit": {
+ "name": "Compit",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"concord232": {
"name": "Concord232",
"integration_type": "hub",
@@ -1151,6 +1163,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "cync": {
+ "name": "Cync",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push"
+ },
"dacia": {
"name": "Dacia",
"integration_type": "virtual",
@@ -1434,6 +1452,12 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "droplet": {
+ "name": "Droplet",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"dsmr": {
"name": "DSMR Smart Meter",
"integration_type": "hub",
@@ -1591,6 +1615,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "ekeybionyx": {
+ "name": "ekey bionyx",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push"
+ },
"electrasmart": {
"name": "Electra Smart",
"integration_type": "hub",
@@ -1644,6 +1674,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "eltako": {
+ "name": "Eltako",
+ "iot_standards": [
+ "matter"
+ ]
+ },
"elv": {
"name": "ELV PCA",
"integration_type": "hub",
@@ -1948,6 +1984,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "firefly_iii": {
+ "name": "Firefly III",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"fireservicerota": {
"name": "FireServiceRota",
"integration_type": "hub",
@@ -2150,25 +2192,25 @@
]
},
"fritzbox": {
- "name": "FRITZ!Box",
+ "name": "FRITZ!",
"integrations": {
"fritz": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
- "name": "AVM FRITZ!Box Tools"
+ "name": "FRITZ!Box Tools"
},
"fritzbox": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
- "name": "AVM FRITZ!SmartHome"
+ "name": "FRITZ!SmartHome"
},
"fritzbox_callmonitor": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
- "name": "AVM FRITZ!Box Call Monitor"
+ "name": "FRITZ!Box Call Monitor"
}
}
},
@@ -2390,17 +2432,11 @@
"iot_class": "cloud_polling",
"name": "Google Drive"
},
- "google_gemini": {
- "integration_type": "virtual",
- "config_flow": false,
- "supported_by": "google_generative_ai_conversation",
- "name": "Google Gemini"
- },
"google_generative_ai_conversation": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
- "name": "Google Generative AI"
+ "name": "Google Gemini"
},
"google_mail": {
"integration_type": "service",
@@ -2711,6 +2747,11 @@
"integration_type": "virtual",
"supported_by": "netatmo"
},
+ "homeassistant_connect_zbt2": {
+ "name": "Home Assistant Connect ZBT-2",
+ "integration_type": "hardware",
+ "config_flow": true
+ },
"homeassistant_green": {
"name": "Home Assistant Green",
"integration_type": "hardware",
@@ -2888,23 +2929,6 @@
"iot_class": "cloud_polling",
"single_config_entry": true
},
- "ibm": {
- "name": "IBM",
- "integrations": {
- "watson_iot": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_push",
- "name": "IBM Watson IoT Platform"
- },
- "watson_tts": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_push",
- "name": "IBM Watson TTS"
- }
- }
- },
"idteck_prox": {
"name": "IDTECK Proximity Reader",
"integration_type": "hub",
@@ -3107,6 +3131,11 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "irm_kmi": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"iron_os": {
"name": "IronOS",
"integration_type": "hub",
@@ -3318,10 +3347,21 @@
"iot_class": "local_push"
},
"konnected": {
- "name": "Konnected.io",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "local_push"
+ "name": "Konnected",
+ "integrations": {
+ "konnected": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "name": "Konnected.io (Legacy)"
+ },
+ "konnected_esphome": {
+ "integration_type": "virtual",
+ "config_flow": false,
+ "supported_by": "esphome",
+ "name": "Konnected"
+ }
+ }
},
"kostal_plenticore": {
"name": "Kostal Plenticore Solar Inverter",
@@ -3448,6 +3488,12 @@
"config_flow": true,
"iot_class": "cloud_push"
},
+ "level": {
+ "name": "Level",
+ "iot_standards": [
+ "matter"
+ ]
+ },
"leviton": {
"name": "Leviton",
"iot_standards": [
@@ -3483,6 +3529,12 @@
}
}
},
+ "libre_hardware_monitor": {
+ "name": "Libre Hardware Monitor",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"lidarr": {
"name": "Lidarr",
"integration_type": "service",
@@ -3664,6 +3716,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "lunatone": {
+ "name": "Lunatone",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"lupusec": {
"name": "Lupus Electronics LUPUSEC",
"integration_type": "hub",
@@ -3863,6 +3921,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "meteo_lt": {
+ "name": "Meteo.lt",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"meteoalarm": {
"name": "MeteoAlarm",
"integration_type": "hub",
@@ -4140,7 +4204,7 @@
"name": "Manual MQTT Alarm Control Panel"
},
"mqtt": {
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": true,
"iot_class": "local_push",
"name": "MQTT"
@@ -4269,8 +4333,8 @@
},
"nederlandse_spoorwegen": {
"name": "Nederlandse Spoorwegen (NS)",
- "integration_type": "hub",
- "config_flow": false,
+ "integration_type": "service",
+ "config_flow": true,
"iot_class": "cloud_polling"
},
"neff": {
@@ -4278,6 +4342,11 @@
"integration_type": "virtual",
"supported_by": "home_connect"
},
+ "neo": {
+ "name": "Neo",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+ },
"ness_alarm": {
"name": "Ness Alarm",
"integration_type": "hub",
@@ -4729,7 +4798,7 @@
}
},
"opnsense": {
- "name": "OPNSense",
+ "name": "OPNsense",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
@@ -5024,12 +5093,6 @@
"config_flow": true,
"iot_class": "local_polling"
},
- "plum_lightpad": {
- "name": "Plum Lightpad",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "local_push"
- },
"pocketcasts": {
"name": "Pocket Casts",
"integration_type": "hub",
@@ -5042,12 +5105,24 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "pooldose": {
+ "name": "SEKO PoolDose",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"poolsense": {
"name": "PoolSense",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "portainer": {
+ "name": "Portainer",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"portlandgeneral": {
"name": "Portland General Electric (PGE)",
"integration_type": "virtual",
@@ -5108,7 +5183,7 @@
},
"prowl": {
"name": "Prowl",
- "integration_type": "hub",
+ "integration_type": "service",
"config_flow": false,
"iot_class": "cloud_push"
},
@@ -5592,6 +5667,12 @@
}
}
},
+ "route_b_smart_meter": {
+ "name": "Smart Meter B Route",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"rova": {
"name": "ROVA",
"integration_type": "hub",
@@ -5705,8 +5786,9 @@
"satel_integra": {
"name": "Satel Integra",
"integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_push"
+ "config_flow": true,
+ "iot_class": "local_push",
+ "single_config_entry": true
},
"schlage": {
"name": "Schlage",
@@ -5859,6 +5941,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "sftp_storage": {
+ "name": "SFTP Storage",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"sharkiq": {
"name": "Shark IQ",
"integration_type": "hub",
@@ -6743,7 +6831,8 @@
"name": "Thread",
"integration_type": "service",
"config_flow": true,
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "single_config_entry": true
},
"tibber": {
"name": "Tibber",
@@ -7222,6 +7311,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "victron_remote_monitoring": {
+ "name": "Victron Remote Monitoring",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"vilfo": {
"name": "Vilfo Router",
"integration_type": "hub",
@@ -7299,18 +7394,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
- "vulcan": {
- "name": "Uonet+ Vulcan",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "cloud_polling"
- },
- "vultr": {
- "name": "Vultr",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_polling"
- },
"w800rf32": {
"name": "WGL Designs W800RF32",
"integration_type": "hub",
@@ -7347,6 +7430,12 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "watson_tts": {
+ "name": "IBM Watson TTS",
+ "integration_type": "hub",
+ "config_flow": false,
+ "iot_class": "cloud_push"
+ },
"watttime": {
"name": "WattTime",
"integration_type": "service",
@@ -7775,12 +7864,6 @@
}
},
"helper": {
- "bayesian": {
- "name": "Bayesian",
- "integration_type": "helper",
- "config_flow": false,
- "iot_class": "local_polling"
- },
"counter": {
"integration_type": "helper",
"config_flow": false
@@ -7939,6 +8022,7 @@
"input_select",
"input_text",
"integration",
+ "irm_kmi",
"islamic_prayer_times",
"local_calendar",
"local_ip",
diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py
index dee0367de24..96cf6752405 100644
--- a/homeassistant/generated/usb.py
+++ b/homeassistant/generated/usb.py
@@ -4,6 +4,12 @@ To update, run python3 -m script.hassfest
"""
USB = [
+ {
+ "description": "*zbt-2*",
+ "domain": "homeassistant_connect_zbt2",
+ "pid": "4001",
+ "vid": "303A",
+ },
{
"description": "*skyconnect v1.0*",
"domain": "homeassistant_sky_connect",
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 742840fa849..2162af50158 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -464,6 +464,11 @@ ZEROCONF = {
"domain": "daikin",
},
],
+ "_droplet._tcp.local.": [
+ {
+ "domain": "droplet",
+ },
+ ],
"_dvl-deviceapi._tcp.local.": [
{
"domain": "devolo_home_control",
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
index cfc250754ec..75fabc81696 100644
--- a/homeassistant/helpers/area_registry.py
+++ b/homeassistant/helpers/area_registry.py
@@ -179,8 +179,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
self._floors_index[entry.floor_id][key] = True
for label in entry.labels:
self._labels_index[label][key] = True
- for alias in entry.aliases:
- normalized_alias = normalize_name(alias)
+ for normalized_alias in {normalize_name(alias) for alias in entry.aliases}:
self._aliases_index[normalized_alias][key] = True
def _unindex_entry(
@@ -190,8 +189,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
super()._unindex_entry(key, replacement_entry)
entry = self.data[key]
if aliases := entry.aliases:
- for alias in aliases:
- normalized_alias = normalize_name(alias)
+ for normalized_alias in {normalize_name(alias) for alias in aliases}:
self._unindex_entry_value(key, normalized_alias, self._aliases_index)
if labels := entry.labels:
for label in labels:
diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py
index 52a0fc13255..85f03d8e13f 100644
--- a/homeassistant/helpers/automation.py
+++ b/homeassistant/helpers/automation.py
@@ -1,5 +1,13 @@
"""Helpers for automation."""
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_OPTIONS
+
+from .typing import ConfigType
+
def get_absolute_description_key(domain: str, key: str) -> str:
"""Return the absolute description key."""
@@ -19,3 +27,26 @@ def get_relative_description_key(domain: str, key: str) -> str:
if not subtype:
return "_"
return subtype[0]
+
+
+def move_top_level_schema_fields_to_options(
+ config: ConfigType, options_schema_dict: dict[vol.Marker, Any]
+) -> ConfigType:
+ """Move top-level fields to options.
+
+ This function is used to help migrating old-style configs to new-style configs.
+ If options is already present, the config is returned as-is.
+ """
+ if CONF_OPTIONS in config:
+ return config
+
+ config = config.copy()
+ options = config.setdefault(CONF_OPTIONS, {})
+
+ # Move top-level fields to options
+ for key_marked in options_schema_dict:
+ key = key_marked.schema
+ if key in config:
+ options[key] = config.pop(key)
+
+ return config
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index d9f16217c2e..7e162b15d8f 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -6,6 +6,7 @@ import abc
from collections import deque
from collections.abc import Callable, Container, Coroutine, Generator, Iterable
from contextlib import contextmanager
+from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
import functools as ft
import inspect
@@ -30,7 +31,10 @@ from homeassistant.const import (
CONF_FOR,
CONF_ID,
CONF_MATCH,
+ CONF_OPTIONS,
+ CONF_SELECTOR,
CONF_STATE,
+ CONF_TARGET,
CONF_VALUE_TEMPLATE,
CONF_WEEKDAY,
ENTITY_MATCH_ALL,
@@ -59,9 +63,10 @@ from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.yaml import load_yaml_dict
-from . import config_validation as cv, entity_registry as er
+from . import config_validation as cv, entity_registry as er, selector
from .automation import get_absolute_description_key, get_relative_description_key
from .integration_platform import async_process_integration_platforms
+from .selector import TargetSelector
from .template import Template, render_complex
from .trace import (
TraceElement,
@@ -109,14 +114,17 @@ CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions")
# Basic schemas to sanity check the condition descriptions,
# full validation is done by hassfest.conditions
-_FIELD_SCHEMA = vol.Schema(
- {},
+_FIELD_DESCRIPTION_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_SELECTOR): selector.validate_selector,
+ },
extra=vol.ALLOW_EXTRA,
)
-_CONDITION_SCHEMA = vol.Schema(
+_CONDITION_DESCRIPTION_SCHEMA = vol.Schema(
{
- vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}),
+ vol.Optional("target"): TargetSelector.CONFIG_SCHEMA,
+ vol.Optional("fields"): vol.Schema({str: _FIELD_DESCRIPTION_SCHEMA}),
},
extra=vol.ALLOW_EXTRA,
)
@@ -129,10 +137,10 @@ def starts_with_dot(key: str) -> str:
return key
-_CONDITIONS_SCHEMA = vol.Schema(
+_CONDITIONS_DESCRIPTION_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, starts_with_dot)): object,
- cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA),
+ cv.underscore_slug: vol.Any(None, _CONDITION_DESCRIPTION_SCHEMA),
}
)
@@ -194,11 +202,43 @@ async def _register_condition_platform(
_LOGGER.exception("Error while notifying condition platform listener")
+_CONDITION_SCHEMA = vol.Schema(
+ {
+ **cv.CONDITION_BASE_SCHEMA,
+ vol.Required(CONF_CONDITION): str,
+ vol.Optional(CONF_OPTIONS): object,
+ vol.Optional(CONF_TARGET): cv.TARGET_FIELDS,
+ }
+)
+
+
class Condition(abc.ABC):
"""Condition class."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize condition."""
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config.
+
+ The complete config includes fields that are generic to all conditions,
+ such as the alias.
+ This method should be overridden by conditions that need to migrate
+ from the old-style config.
+ """
+ complete_config = _CONDITION_SCHEMA(complete_config)
+
+ specific_config: ConfigType = {}
+ for key in (CONF_OPTIONS, CONF_TARGET):
+ if key in complete_config:
+ specific_config[key] = complete_config.pop(key)
+ specific_config = await cls.async_validate_config(hass, specific_config)
+
+ for key in (CONF_OPTIONS, CONF_TARGET):
+ if key in specific_config:
+ complete_config[key] = specific_config[key]
+
+ return complete_config
@classmethod
@abc.abstractmethod
@@ -207,6 +247,9 @@ class Condition(abc.ABC):
) -> ConfigType:
"""Validate config."""
+ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
+ """Initialize condition."""
+
@abc.abstractmethod
async def async_get_checker(self) -> ConditionCheckerType:
"""Get the condition checker."""
@@ -221,6 +264,14 @@ class ConditionProtocol(Protocol):
"""Return the conditions provided by this integration."""
+@dataclass(slots=True)
+class ConditionConfig:
+ """Condition config."""
+
+ options: dict[str, Any] | None = None
+ target: dict[str, Any] | None = None
+
+
type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None]
@@ -350,8 +401,15 @@ async def async_from_config(
relative_condition_key = get_relative_description_key(
platform_domain, condition_key
)
- condition_instance = condition_descriptors[relative_condition_key](hass, config)
- return await condition_instance.async_get_checker()
+ condition_cls = condition_descriptors[relative_condition_key]
+ condition = condition_cls(
+ hass,
+ ConditionConfig(
+ options=config.get(CONF_OPTIONS),
+ target=config.get(CONF_TARGET),
+ ),
+ )
+ return await condition.async_get_checker()
for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT):
factory = getattr(sys.modules[__name__], fmt.format(condition_key), None)
@@ -984,9 +1042,9 @@ async def async_validate_condition_config(
)
if not (condition_class := condition_descriptors.get(relative_condition_key)):
raise vol.Invalid(f"Invalid condition '{condition_key}' specified")
- return await condition_class.async_validate_config(hass, config)
+ return await condition_class.async_validate_complete_config(hass, config)
- if platform is None and condition_key in ("numeric_state", "state"):
+ if condition_key in ("numeric_state", "state"):
validator = cast(
Callable[[HomeAssistant, ConfigType], ConfigType],
getattr(
@@ -1106,7 +1164,7 @@ def _load_conditions_file(integration: Integration) -> dict[str, Any]:
try:
return cast(
dict[str, Any],
- _CONDITIONS_SCHEMA(
+ _CONDITIONS_DESCRIPTION_SCHEMA(
load_yaml_dict(str(integration.file_path / "conditions.yaml"))
),
)
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index c2ebddf8012..cc46327c4c1 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -373,6 +373,17 @@ def entity_id(value: Any) -> str:
raise vol.Invalid(f"Entity ID {value} is an invalid entity ID")
+def strict_entity_id(value: Any) -> str:
+ """Validate Entity ID, strictly."""
+ if not isinstance(value, str):
+ raise vol.Invalid(f"Entity ID {value} is not a string")
+
+ if valid_entity_id(value):
+ return value
+
+ raise vol.Invalid(f"Entity ID {value} is not a valid entity ID")
+
+
def entity_id_or_uuid(value: Any) -> str:
"""Validate Entity specified by entity_id or uuid."""
with contextlib.suppress(vol.Invalid):
@@ -728,15 +739,7 @@ def template(value: Any | None) -> template_helper.Template:
if isinstance(value, (list, dict, template_helper.Template)):
raise vol.Invalid("template value should be a string")
if not (hass := _async_get_hass_or_none()):
- from .frame import ReportBehavior, report_usage # noqa: PLC0415
-
- report_usage(
- (
- "validates schema outside the event loop, "
- "which will stop working in HA Core 2025.10"
- ),
- core_behavior=ReportBehavior.LOG,
- )
+ raise vol.Invalid("Validates schema outside the event loop")
template_value = template_helper.Template(str(value), hass)
@@ -1097,11 +1100,21 @@ def key_value_schemas(
value_schemas: ValueSchemas,
default_schema: VolSchemaType | Callable[[Any], dict[str, Any]] | None = None,
default_description: str | None = None,
+ list_alternatives: bool = True,
) -> Callable[[Any], dict[Hashable, Any]]:
"""Create a validator that validates based on a value for specific key.
This gives better error messages.
+
+ default_schema: An optional schema to use if the key value is not in value_schemas.
+ default_description: A description of what is expected by the default schema, this
+ will be added to the error message.
+ list_alternatives: If True, list the keys in `value_schemas` in the error message.
"""
+ if not list_alternatives and not default_description:
+ raise ValueError(
+ "default_description must be provided if list_alternatives is False"
+ )
def key_value_validator(value: Any) -> dict[Hashable, Any]:
if not isinstance(value, dict):
@@ -1116,9 +1129,13 @@ def key_value_schemas(
with contextlib.suppress(vol.Invalid):
return cast(dict[Hashable, Any], default_schema(value))
- alternatives = ", ".join(str(alternative) for alternative in value_schemas)
- if default_description:
- alternatives = f"{alternatives}, {default_description}"
+ if list_alternatives:
+ alternatives = ", ".join(str(alternative) for alternative in value_schemas)
+ if default_description:
+ alternatives = f"{alternatives}, {default_description}"
+ else:
+ # mypy does not understand that default_description is not None here
+ alternatives = default_description # type: ignore[assignment]
raise vol.Invalid(
f"Unexpected value for {key}: '{key_value}'. Expected {alternatives}"
)
@@ -1292,6 +1309,15 @@ PLATFORM_SCHEMA = vol.Schema(
PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
+
+TARGET_FIELDS: VolDictType = {
+ vol.Optional(ATTR_ENTITY_ID): vol.All(ensure_list, [strict_entity_id]),
+ vol.Optional(ATTR_DEVICE_ID): vol.All(ensure_list, [str]),
+ vol.Optional(ATTR_AREA_ID): vol.All(ensure_list, [str]),
+ vol.Optional(ATTR_FLOOR_ID): vol.All(ensure_list, [str]),
+ vol.Optional(ATTR_LABEL_ID): vol.All(ensure_list, [str]),
+}
+
ENTITY_SERVICE_FIELDS: VolDictType = {
# Either accept static entity IDs, a single dynamic template or a mixed list
# of static and dynamic templates. While this could be solved with a single
@@ -1511,9 +1537,6 @@ STATE_CONDITION_BASE_SCHEMA = {
),
vol.Optional(CONF_ATTRIBUTE): str,
vol.Optional(CONF_FOR): positive_time_period_template,
- # To support use_trigger_value in automation
- # Deprecated 2016/04/25
- vol.Optional("from"): str,
}
STATE_CONDITION_STATE_SCHEMA = vol.Schema(
@@ -1733,7 +1756,7 @@ def _base_condition_validator(value: Any) -> Any:
vol.Schema(
{
**CONDITION_BASE_SCHEMA,
- CONF_CONDITION: vol.NotIn(BUILT_IN_CONDITIONS),
+ CONF_CONDITION: vol.All(str, vol.NotIn(BUILT_IN_CONDITIONS)),
},
extra=vol.ALLOW_EXTRA,
)(value)
@@ -1748,6 +1771,8 @@ CONDITION_SCHEMA: vol.Schema = vol.Schema(
CONF_CONDITION,
BUILT_IN_CONDITIONS,
_base_condition_validator,
+ "a condition, a list of conditions or a valid template",
+ list_alternatives=False,
),
),
dynamic_template_condition,
@@ -1779,7 +1804,8 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema(
dynamic_template_condition_action,
_base_condition_validator,
),
- "a list of conditions or a valid template",
+ "a condition, a list of conditions or a valid template",
+ list_alternatives=False,
),
)
)
diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py
index c46c6806d5d..a562f86f1f9 100644
--- a/homeassistant/helpers/debounce.py
+++ b/homeassistant/helpers/debounce.py
@@ -85,11 +85,8 @@ class Debouncer[_R_co]:
return False
- # Locked means a call is in progress. Any call is good, so abort.
- if self._execute_lock.locked():
- return False
-
- if not self.immediate:
+ # If not immediate or in progress, we schedule a call for later.
+ if not self.immediate or self._execute_lock.locked():
self._execute_at_end_of_timer = True
self._schedule_timer()
return False
diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py
index 29d9237de05..6dfb002305a 100644
--- a/homeassistant/helpers/deprecation.py
+++ b/homeassistant/helpers/deprecation.py
@@ -138,6 +138,41 @@ def deprecated_function[**_P, _R](
return deprecated_decorator
+def deprecated_hass_argument[**_P, _T](
+ breaks_in_ha_version: str | None = None,
+) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
+ """Decorate function to indicate that first argument hass will be ignored."""
+
+ def _decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
+ @functools.wraps(func)
+ def _inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
+ from homeassistant.core import HomeAssistant # noqa: PLC0415
+
+ in_arg = len(args) > 0 and isinstance(args[0], HomeAssistant)
+ in_kwarg = "hass" in kwargs and isinstance(kwargs["hass"], HomeAssistant)
+
+ if in_arg or in_kwarg:
+ _print_deprecation_warning_internal(
+ "hass",
+ func.__module__,
+ f"{func.__name__} without hass argument",
+ "argument",
+ f"passed to {func.__name__}",
+ breaks_in_ha_version,
+ log_when_no_integration_is_found=True,
+ )
+ if in_arg:
+ args = args[1:] # type: ignore[assignment]
+ if in_kwarg:
+ kwargs.pop("hass")
+
+ return func(*args, **kwargs)
+
+ return _inner
+
+ return _decorator
+
+
def _print_deprecation_warning(
obj: Any,
replacement: str,
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 463b5c4dddc..ef9c6b26b9f 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -57,7 +57,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event
)
STORAGE_KEY = "core.device_registry"
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 11
+STORAGE_VERSION_MINOR = 12
CLEANUP_DELAY = 10
@@ -349,8 +349,6 @@ class DeviceEntry:
_suggested_area: str | None = attr.ib(default=None)
sw_version: str | None = attr.ib(default=None)
via_device_id: str | None = attr.ib(default=None)
- # This value is not stored, just used to keep track of events to fire.
- is_new: bool = attr.ib(default=False)
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
@property
@@ -465,7 +463,7 @@ class DeletedDeviceEntry:
validator=_normalize_connections_validator
)
created_at: datetime = attr.ib()
- disabled_by: DeviceEntryDisabler | None = attr.ib()
+ disabled_by: DeviceEntryDisabler | UndefinedType | None = attr.ib()
id: str = attr.ib()
identifiers: set[tuple[str, str]] = attr.ib()
labels: set[str] = attr.ib()
@@ -476,23 +474,33 @@ class DeletedDeviceEntry:
def to_device_entry(
self,
- config_entry_id: str,
+ config_entry: ConfigEntry,
config_subentry_id: str | None,
connections: set[tuple[str, str]],
identifiers: set[tuple[str, str]],
+ disabled_by: DeviceEntryDisabler | UndefinedType | None,
) -> DeviceEntry:
"""Create DeviceEntry from DeletedDeviceEntry."""
+ # Adjust disabled_by based on config entry state
+ if self.disabled_by is not UNDEFINED:
+ disabled_by = self.disabled_by
+ if config_entry.disabled_by:
+ if disabled_by is None:
+ disabled_by = DeviceEntryDisabler.CONFIG_ENTRY
+ elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY:
+ disabled_by = None
+ else:
+ disabled_by = disabled_by if disabled_by is not UNDEFINED else None
return DeviceEntry(
area_id=self.area_id,
# type ignores: likely https://github.com/python/mypy/issues/8625
- config_entries={config_entry_id}, # type: ignore[arg-type]
- config_entries_subentries={config_entry_id: {config_subentry_id}},
+ config_entries={config_entry.entry_id}, # type: ignore[arg-type]
+ config_entries_subentries={config_entry.entry_id: {config_subentry_id}},
connections=self.connections & connections, # type: ignore[arg-type]
created_at=self.created_at,
- disabled_by=self.disabled_by,
+ disabled_by=disabled_by,
identifiers=self.identifiers & identifiers, # type: ignore[arg-type]
id=self.id,
- is_new=True,
labels=self.labels, # type: ignore[arg-type]
name_by_user=self.name_by_user,
)
@@ -513,7 +521,10 @@ class DeletedDeviceEntry:
},
"connections": list(self.connections),
"created_at": self.created_at,
- "disabled_by": self.disabled_by,
+ "disabled_by": self.disabled_by
+ if self.disabled_by is not UNDEFINED
+ else None,
+ "disabled_by_undefined": self.disabled_by is UNDEFINED,
"identifiers": list(self.identifiers),
"id": self.id,
"labels": list(self.labels),
@@ -614,6 +625,11 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
device["connections"] = _normalize_connections(
device["connections"]
)
+ if old_minor_version < 12:
+ # Version 1.12 adds undefined flags to deleted devices, this is a bugfix
+ # of version 1.10
+ for device in old_data["deleted_devices"]:
+ device["disabled_by_undefined"] = old_minor_version < 10
if old_major_version > 2:
raise NotImplementedError
@@ -824,7 +840,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
default_manufacturer: str | None | UndefinedType = UNDEFINED,
default_model: str | None | UndefinedType = UNDEFINED,
default_name: str | None | UndefinedType = UNDEFINED,
- # To disable a device if it gets created
+ # To disable a device if it gets created, does not affect existing devices
disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
hw_version: str | None | UndefinedType = UNDEFINED,
@@ -903,7 +919,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
identifiers=identifiers, connections=connections
)
+ is_new = False
+
if device is None:
+ is_new = True
+
deleted_device = self.deleted_devices.get_entry(identifiers, connections)
if deleted_device is None:
area_id: str | None = None
@@ -917,17 +937,20 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
area = ar.async_get(self.hass).async_get_or_create(suggested_area)
area_id = area.id
- device = DeviceEntry(is_new=True, area_id=area_id)
+ device = DeviceEntry(area_id=area_id)
else:
self.deleted_devices.pop(deleted_device.id)
device = deleted_device.to_device_entry(
- config_entry_id,
+ config_entry,
# Interpret not specifying a subentry as None
config_subentry_id if config_subentry_id is not UNDEFINED else None,
connections,
identifiers,
+ disabled_by,
)
+ disabled_by = UNDEFINED
+
self.devices[device.id] = device
# If creating a new device, default to the config entry name
if device_info_type == "primary" and (not name or name is UNDEFINED):
@@ -956,7 +979,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
else:
via_device_id = UNDEFINED
- device = self.async_update_device(
+ device = self._async_update_device(
device.id,
allow_collisions=True,
add_config_entry_id=config_entry_id,
@@ -966,6 +989,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
disabled_by=disabled_by,
entry_type=entry_type,
hw_version=hw_version,
+ is_new=is_new,
manufacturer=manufacturer,
merge_connections=connections or UNDEFINED,
merge_identifiers=identifiers or UNDEFINED,
@@ -973,7 +997,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
model_id=model_id,
name=name,
serial_number=serial_number,
- _suggested_area=suggested_area,
+ suggested_area=suggested_area,
sw_version=sw_version,
via_device_id=via_device_id,
)
@@ -984,14 +1008,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
return device
@callback
- def async_update_device( # noqa: C901
+ def _async_update_device( # noqa: C901
self,
device_id: str,
*,
add_config_entry_id: str | UndefinedType = UNDEFINED,
add_config_subentry_id: str | None | UndefinedType = UNDEFINED,
# Temporary flag so we don't blow up when collisions are implicitly introduced
- # by calls to async_get_or_create. Must not be set by integrations.
+ # by calls to async_get_or_create.
allow_collisions: bool = False,
area_id: str | None | UndefinedType = UNDEFINED,
configuration_url: str | URL | None | UndefinedType = UNDEFINED,
@@ -999,6 +1023,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
hw_version: str | None | UndefinedType = UNDEFINED,
+ is_new: bool = False,
labels: set[str] | UndefinedType = UNDEFINED,
manufacturer: str | None | UndefinedType = UNDEFINED,
merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
@@ -1012,15 +1037,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
remove_config_entry_id: str | UndefinedType = UNDEFINED,
remove_config_subentry_id: str | None | UndefinedType = UNDEFINED,
serial_number: str | None | UndefinedType = UNDEFINED,
- # _suggested_area is used internally by the device registry and must
- # not be set by integrations.
- _suggested_area: str | None | UndefinedType = UNDEFINED,
- # suggested_area is deprecated and will be removed in 2026.9
+ # Can be removed when suggested_area is removed from DeviceEntry
suggested_area: str | None | UndefinedType = UNDEFINED,
sw_version: str | None | UndefinedType = UNDEFINED,
via_device_id: str | None | UndefinedType = UNDEFINED,
) -> DeviceEntry | None:
- """Update device attributes.
+ """Private update device attributes.
:param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id
:param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id
@@ -1108,6 +1130,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
config_entries_subentries = old.config_entries_subentries | {
add_config_entry_id: {add_config_subentry_id}
}
+ # Enable the device if it was disabled by config entry and we're adding
+ # a non disabled config entry
+ if (
+ # mypy says add_config_entry can be None. That's impossible, because we
+ # raise above if that happens
+ not add_config_entry.disabled_by # type: ignore[union-attr]
+ and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY
+ ):
+ new_values["disabled_by"] = None
+ old_values["disabled_by"] = old.disabled_by
elif (
add_config_subentry_id
not in old.config_entries_subentries[add_config_entry_id]
@@ -1150,6 +1182,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
config_entries = config_entries - {remove_config_entry_id}
+ # Disable the device if it is enabled and all remaining config entries
+ # are disabled
+ has_enabled_config_entries = any(
+ config_entry.disabled_by is None
+ for config_entry_id in config_entries
+ if (
+ config_entry := self.hass.config_entries.async_get_entry(
+ config_entry_id
+ )
+ )
+ is not None
+ )
+ if not has_enabled_config_entries and old.disabled_by is None:
+ new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY
+ old_values["disabled_by"] = old.disabled_by
+
if config_entries != old.config_entries:
new_values["config_entries"] = config_entries
old_values["config_entries"] = old.config_entries
@@ -1158,16 +1206,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_values["config_entries_subentries"] = config_entries_subentries
old_values["config_entries_subentries"] = old.config_entries_subentries
- if suggested_area is not UNDEFINED:
- report_usage(
- "passes a suggested_area to device_registry.async_update device",
- core_behavior=ReportBehavior.LOG,
- breaks_in_ha_version="2026.9.0",
- )
-
- if _suggested_area is not UNDEFINED:
- suggested_area = _suggested_area
-
added_connections: set[tuple[str, str]] | None = None
added_identifiers: set[tuple[str, str]] | None = None
@@ -1233,10 +1271,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_values["suggested_area"] = suggested_area
old_values["suggested_area"] = old._suggested_area # noqa: SLF001
- if old.is_new:
- new_values["is_new"] = False
-
- if not new_values:
+ if not new_values and not is_new:
return old
# This condition can be removed when suggested_area is removed from DeviceEntry
@@ -1244,7 +1279,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# Change modified_at if we are changing something that we store
new_values["modified_at"] = utcnow()
- self.hass.verify_event_loop_thread("device_registry.async_update_device")
+ self.hass.verify_event_loop_thread("device_registry._async_update_device")
new = attr.evolve(old, **new_values)
self.devices[device_id] = new
@@ -1268,7 +1303,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self.async_schedule_save()
data: EventDeviceRegistryUpdatedData
- if old.is_new:
+ if is_new:
data = {"action": "create", "device_id": new.id}
else:
data = {"action": "update", "device_id": new.id, "changes": old_values}
@@ -1277,6 +1312,77 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
return new
+ @callback
+ def async_update_device(
+ self,
+ device_id: str,
+ *,
+ add_config_entry_id: str | UndefinedType = UNDEFINED,
+ add_config_subentry_id: str | None | UndefinedType = UNDEFINED,
+ area_id: str | None | UndefinedType = UNDEFINED,
+ configuration_url: str | URL | None | UndefinedType = UNDEFINED,
+ device_info_type: str | UndefinedType = UNDEFINED,
+ disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED,
+ entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED,
+ hw_version: str | None | UndefinedType = UNDEFINED,
+ labels: set[str] | UndefinedType = UNDEFINED,
+ manufacturer: str | None | UndefinedType = UNDEFINED,
+ merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
+ merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
+ model: str | None | UndefinedType = UNDEFINED,
+ model_id: str | None | UndefinedType = UNDEFINED,
+ name_by_user: str | None | UndefinedType = UNDEFINED,
+ name: str | None | UndefinedType = UNDEFINED,
+ new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
+ new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
+ remove_config_entry_id: str | UndefinedType = UNDEFINED,
+ remove_config_subentry_id: str | None | UndefinedType = UNDEFINED,
+ serial_number: str | None | UndefinedType = UNDEFINED,
+ # suggested_area is deprecated and will be removed in 2026.9
+ suggested_area: str | None | UndefinedType = UNDEFINED,
+ sw_version: str | None | UndefinedType = UNDEFINED,
+ via_device_id: str | None | UndefinedType = UNDEFINED,
+ ) -> DeviceEntry | None:
+ """Update device attributes.
+
+ :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id
+ :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id
+ """
+ if suggested_area is not UNDEFINED:
+ report_usage(
+ "passes a suggested_area to device_registry.async_update device",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2026.9.0",
+ )
+
+ return self._async_update_device(
+ device_id,
+ add_config_entry_id=add_config_entry_id,
+ add_config_subentry_id=add_config_subentry_id,
+ area_id=area_id,
+ configuration_url=configuration_url,
+ device_info_type=device_info_type,
+ disabled_by=disabled_by,
+ entry_type=entry_type,
+ hw_version=hw_version,
+ labels=labels,
+ manufacturer=manufacturer,
+ merge_connections=merge_connections,
+ merge_identifiers=merge_identifiers,
+ model=model,
+ model_id=model_id,
+ name_by_user=name_by_user,
+ name=name,
+ new_connections=new_connections,
+ new_identifiers=new_identifiers,
+ remove_config_entry_id=remove_config_entry_id,
+ remove_config_subentry_id=remove_config_subentry_id,
+ serial_number=serial_number,
+ suggested_area=suggested_area,
+ sw_version=sw_version,
+ via_device_id=via_device_id,
+ )
+
@callback
def _validate_connections(
self,
@@ -1345,7 +1451,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
)
for other_device in list(self.devices.values()):
if other_device.via_device_id == device_id:
- self.async_update_device(other_device.id, via_device_id=None)
+ self._async_update_device(other_device.id, via_device_id=None)
self.hass.bus.async_fire_internal(
EVENT_DEVICE_REGISTRY_UPDATED,
_EventDeviceRegistryUpdatedData_Remove(
@@ -1409,7 +1515,21 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
sw_version=device["sw_version"],
via_device_id=device["via_device_id"],
)
+
# Introduced in 0.111
+ def get_optional_enum[_EnumT: StrEnum](
+ cls: type[_EnumT], value: str | None, undefined: bool
+ ) -> _EnumT | UndefinedType | None:
+ """Convert string to the passed enum, UNDEFINED or None."""
+ if undefined:
+ return UNDEFINED
+ if value is None:
+ return None
+ try:
+ return cls(value)
+ except ValueError:
+ return None
+
for device in data["deleted_devices"]:
deleted_devices[device["id"]] = DeletedDeviceEntry(
area_id=device["area_id"],
@@ -1422,10 +1542,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
},
connections={tuple(conn) for conn in device["connections"]},
created_at=datetime.fromisoformat(device["created_at"]),
- disabled_by=(
- DeviceEntryDisabler(device["disabled_by"])
- if device["disabled_by"]
- else None
+ disabled_by=get_optional_enum(
+ DeviceEntryDisabler,
+ device["disabled_by"],
+ device["disabled_by_undefined"],
),
identifiers={tuple(iden) for iden in device["identifiers"]},
id=device["id"],
@@ -1454,24 +1574,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"""Clear config entry from registry entries."""
now_time = time.time()
for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
- self.async_update_device(device.id, remove_config_entry_id=config_entry_id)
+ self._async_update_device(device.id, remove_config_entry_id=config_entry_id)
for deleted_device in list(self.deleted_devices.values()):
config_entries = deleted_device.config_entries
if config_entry_id not in config_entries:
continue
if config_entries == {config_entry_id}:
- # Clear disabled_by if it was disabled by the config entry
- if deleted_device.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY:
- disabled_by = None
- else:
- disabled_by = deleted_device.disabled_by
# Add a time stamp when the deleted device became orphaned
self.deleted_devices[deleted_device.id] = attr.evolve(
deleted_device,
orphaned_timestamp=now_time,
config_entries=set(),
config_entries_subentries={},
- disabled_by=disabled_by,
)
else:
config_entries = config_entries - {config_entry_id}
@@ -1494,9 +1608,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
) -> None:
"""Clear config entry from registry entries."""
now_time = time.time()
- now_time = time.time()
for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
- self.async_update_device(
+ self._async_update_device(
device.id,
remove_config_entry_id=config_entry_id,
remove_config_subentry_id=config_subentry_id,
@@ -1557,7 +1670,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
def async_clear_area_id(self, area_id: str) -> None:
"""Clear area id from registry entries."""
for device in self.devices.get_devices_for_area_id(area_id):
- self.async_update_device(device.id, area_id=None)
+ self._async_update_device(device.id, area_id=None)
for deleted_device in list(self.deleted_devices.values()):
if deleted_device.area_id != area_id:
continue
@@ -1570,7 +1683,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
def async_clear_label_id(self, label_id: str) -> None:
"""Clear label from registry entries."""
for device in self.devices.get_devices_for_label(label_id):
- self.async_update_device(device.id, labels=device.labels - {label_id})
+ self._async_update_device(device.id, labels=device.labels - {label_id})
for deleted_device in list(self.deleted_devices.values()):
if label_id not in deleted_device.labels:
continue
@@ -1634,7 +1747,7 @@ def async_config_entry_disabled_by_changed(
for device in devices:
if device.disabled_by is not DeviceEntryDisabler.CONFIG_ENTRY:
continue
- registry.async_update_device(device.id, disabled_by=None)
+ registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001
return
enabled_config_entries = {
@@ -1651,7 +1764,7 @@ def async_config_entry_disabled_by_changed(
enabled_config_entries
):
continue
- registry.async_update_device(
+ registry._async_update_device( # noqa: SLF001
device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY
)
@@ -1689,7 +1802,7 @@ def async_cleanup(
for device in list(dev_reg.devices.values()):
for config_entry_id in device.config_entries:
if config_entry_id not in config_entry_ids:
- dev_reg.async_update_device(
+ dev_reg._async_update_device( # noqa: SLF001
device.id, remove_config_entry_id=config_entry_id
)
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index 94dd97a9af9..2baeb31bdc8 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -18,11 +18,9 @@ from homeassistant.const import (
)
from homeassistant.core import (
Event,
- HassJob,
HassJobType,
HomeAssistant,
ServiceCall,
- ServiceResponse,
SupportsResponse,
callback,
)
@@ -31,14 +29,7 @@ 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 (
- config_validation as cv,
- device_registry as dr,
- discovery,
- entity,
- entity_registry as er,
- service,
-)
+from . import device_registry as dr, discovery, entity, entity_registry as er, service
from .entity_platform import EntityPlatform, async_calculate_suggested_object_id
from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType
@@ -249,44 +240,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
This method must be run in the event loop.
"""
return await service.async_extract_entities(
- self.hass, self.entities, service_call, expand_group
- )
-
- @callback
- def async_register_legacy_entity_service(
- self,
- name: str,
- schema: VolDictType | VolSchemaType,
- func: str | Callable[..., Any],
- required_features: list[int] | None = None,
- supports_response: SupportsResponse = SupportsResponse.NONE,
- ) -> None:
- """Register an entity service with a legacy response format."""
- if isinstance(schema, dict):
- schema = cv.make_entity_service_schema(schema)
-
- service_func: str | HassJob[..., Any]
- service_func = func if isinstance(func, str) else HassJob(func)
-
- async def handle_service(
- call: ServiceCall,
- ) -> ServiceResponse:
- """Handle the service."""
-
- result = await service.entity_service_call(
- self.hass, self._entities, service_func, call, required_features
- )
-
- if result:
- if len(result) > 1:
- raise HomeAssistantError(
- "Deprecated service call matched more than one entity"
- )
- return result.popitem()[1]
- return None
-
- self.hass.services.async_register(
- self.domain, name, handle_service, schema, supports_response
+ self.entities, service_call, expand_group
)
@callback
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index bf089dae765..0a676351ee0 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -1068,7 +1068,7 @@ class EntityPlatform:
This method must be run in the event loop.
"""
return await service.async_extract_entities(
- self.hass, self.entities.values(), service_call, expand_group
+ self.entities.values(), service_call, expand_group
)
@callback
@@ -1079,6 +1079,8 @@ class EntityPlatform:
func: str | Callable[..., Any],
required_features: Iterable[int] | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
+ *,
+ entity_device_classes: Iterable[str | None] | None = None,
) -> None:
"""Register an entity service.
@@ -1091,6 +1093,7 @@ class EntityPlatform:
self.hass,
self.platform_name,
name,
+ entity_device_classes=entity_device_classes,
entities=self.domain_platform_entities,
func=func,
job_type=None,
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 2125c0f4512..3b0cb67f6a2 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 18
+STORAGE_VERSION_MINOR = 19
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -164,6 +164,17 @@ def _protect_entity_options(
return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()})
+def _protect_optional_entity_options(
+ data: EntityOptionsType | UndefinedType | None,
+) -> ReadOnlyEntityOptionsType | UndefinedType:
+ """Protect entity options from being modified."""
+ if data is UNDEFINED:
+ return UNDEFINED
+ if data is None:
+ return ReadOnlyDict({})
+ return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()})
+
+
@attr.s(frozen=True, kw_only=True, slots=True)
class RegistryEntry:
"""Entity Registry Entry."""
@@ -414,15 +425,17 @@ class DeletedRegistryEntry:
config_subentry_id: str | None = attr.ib()
created_at: datetime = attr.ib()
device_class: str | None = attr.ib()
- disabled_by: RegistryEntryDisabler | None = attr.ib()
+ disabled_by: RegistryEntryDisabler | UndefinedType | None = attr.ib()
domain: str = attr.ib(init=False, repr=False)
- hidden_by: RegistryEntryHider | None = attr.ib()
+ hidden_by: RegistryEntryHider | UndefinedType | None = attr.ib()
icon: str | None = attr.ib()
id: str = attr.ib()
labels: set[str] = attr.ib()
modified_at: datetime = attr.ib()
name: str | None = attr.ib()
- options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options)
+ options: ReadOnlyEntityOptionsType | UndefinedType = attr.ib(
+ converter=_protect_optional_entity_options
+ )
orphaned_timestamp: float | None = attr.ib()
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
@@ -445,15 +458,22 @@ class DeletedRegistryEntry:
"config_subentry_id": self.config_subentry_id,
"created_at": self.created_at,
"device_class": self.device_class,
- "disabled_by": self.disabled_by,
+ "disabled_by": self.disabled_by
+ if self.disabled_by is not UNDEFINED
+ else None,
+ "disabled_by_undefined": self.disabled_by is UNDEFINED,
"entity_id": self.entity_id,
- "hidden_by": self.hidden_by,
+ "hidden_by": self.hidden_by
+ if self.hidden_by is not UNDEFINED
+ else None,
+ "hidden_by_undefined": self.hidden_by is UNDEFINED,
"icon": self.icon,
"id": self.id,
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.name,
- "options": self.options,
+ "options": self.options if self.options is not UNDEFINED else {},
+ "options_undefined": self.options is UNDEFINED,
"orphaned_timestamp": self.orphaned_timestamp,
"platform": self.platform,
"unique_id": self.unique_id,
@@ -590,6 +610,14 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
entity["labels"] = []
entity["name"] = None
entity["options"] = {}
+ if old_minor_version < 19:
+ # Version 1.19 adds undefined flags to deleted entities, this is a bugfix
+ # of version 1.18
+ set_to_undefined = old_minor_version < 18
+ for entity in data["deleted_entities"]:
+ entity["disabled_by_undefined"] = set_to_undefined
+ entity["hidden_by_undefined"] = set_to_undefined
+ entity["options_undefined"] = set_to_undefined
if old_major_version > 1:
raise NotImplementedError
@@ -887,7 +915,8 @@ class EntityRegistry(BaseRegistry):
# To influence entity ID generation
calculated_object_id: str | None = None,
suggested_object_id: str | None = None,
- # To disable or hide an entity if it gets created
+ # To disable or hide an entity if it gets created, does not affect
+ # existing entities
disabled_by: RegistryEntryDisabler | None = None,
hidden_by: RegistryEntryHider | None = None,
# Function to generate initial entity options if it gets created
@@ -958,16 +987,30 @@ class EntityRegistry(BaseRegistry):
categories = deleted_entity.categories
created_at = deleted_entity.created_at
device_class = deleted_entity.device_class
- disabled_by = deleted_entity.disabled_by
+ if deleted_entity.disabled_by is not UNDEFINED:
+ disabled_by = deleted_entity.disabled_by
+ # Adjust disabled_by based on config entry state
+ if config_entry and config_entry is not UNDEFINED:
+ if config_entry.disabled_by:
+ if disabled_by is None:
+ disabled_by = RegistryEntryDisabler.CONFIG_ENTRY
+ elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY:
+ disabled_by = None
+ elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY:
+ disabled_by = None
# Restore entity_id if it's available
if self._entity_id_available(deleted_entity.entity_id):
entity_id = deleted_entity.entity_id
entity_registry_id = deleted_entity.id
- hidden_by = deleted_entity.hidden_by
+ if deleted_entity.hidden_by is not UNDEFINED:
+ hidden_by = deleted_entity.hidden_by
icon = deleted_entity.icon
labels = deleted_entity.labels
name = deleted_entity.name
- options = deleted_entity.options
+ if deleted_entity.options is not UNDEFINED:
+ options = deleted_entity.options
+ else:
+ options = get_initial_options() if get_initial_options else None
else:
aliases = set()
area_id = None
@@ -1178,7 +1221,7 @@ class EntityRegistry(BaseRegistry):
return
# Ignore device disabled by config entry, this is handled by
- # async_config_entry_disabled
+ # async_config_entry_disabled_by_changed
if device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY:
return
@@ -1271,6 +1314,20 @@ class EntityRegistry(BaseRegistry):
unique_id=new_unique_id,
)
+ if disabled_by is UNDEFINED and config_entry_id is not UNDEFINED:
+ if config_entry_id:
+ config_entry = self.hass.config_entries.async_get_entry(config_entry_id)
+ if TYPE_CHECKING:
+ # We've checked the config_entry exists in _validate_item
+ assert config_entry is not None
+ if config_entry.disabled_by:
+ if old.disabled_by is None:
+ new_values["disabled_by"] = RegistryEntryDisabler.CONFIG_ENTRY
+ elif old.disabled_by == RegistryEntryDisabler.CONFIG_ENTRY:
+ new_values["disabled_by"] = None
+ elif old.disabled_by == RegistryEntryDisabler.CONFIG_ENTRY:
+ new_values["disabled_by"] = None
+
if new_entity_id is not UNDEFINED and new_entity_id != old.entity_id:
if not self._entity_id_available(new_entity_id):
raise ValueError("Entity with this ID is already registered")
@@ -1506,6 +1563,20 @@ class EntityRegistry(BaseRegistry):
previous_unique_id=entity["previous_unique_id"],
unit_of_measurement=entity["unit_of_measurement"],
)
+
+ def get_optional_enum[_EnumT: StrEnum](
+ cls: type[_EnumT], value: str | None, undefined: bool
+ ) -> _EnumT | UndefinedType | None:
+ """Convert string to the passed enum, UNDEFINED or None."""
+ if undefined:
+ return UNDEFINED
+ if value is None:
+ return None
+ try:
+ return cls(value)
+ except ValueError:
+ return None
+
for entity in data["deleted_entities"]:
try:
domain = split_entity_id(entity["entity_id"])[0]
@@ -1531,23 +1602,25 @@ class EntityRegistry(BaseRegistry):
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
- disabled_by=(
- RegistryEntryDisabler(entity["disabled_by"])
- if entity["disabled_by"]
- else None
+ disabled_by=get_optional_enum(
+ RegistryEntryDisabler,
+ entity["disabled_by"],
+ entity["disabled_by_undefined"],
),
entity_id=entity["entity_id"],
- hidden_by=(
- RegistryEntryHider(entity["hidden_by"])
- if entity["hidden_by"]
- else None
+ hidden_by=get_optional_enum(
+ RegistryEntryHider,
+ entity["hidden_by"],
+ entity["hidden_by_undefined"],
),
icon=entity["icon"],
id=entity["id"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name"],
- options=entity["options"],
+ options=entity["options"]
+ if not entity["options_undefined"]
+ else UNDEFINED,
orphaned_timestamp=entity["orphaned_timestamp"],
platform=entity["platform"],
unique_id=entity["unique_id"],
@@ -1613,17 +1686,9 @@ class EntityRegistry(BaseRegistry):
for key, deleted_entity in list(self.deleted_entities.items()):
if config_entry_id != deleted_entity.config_entry_id:
continue
- # Clear disabled_by if it was disabled by the config entry
- if deleted_entity.disabled_by is RegistryEntryDisabler.CONFIG_ENTRY:
- disabled_by = None
- else:
- disabled_by = deleted_entity.disabled_by
# Add a time stamp when the deleted entity became orphaned
self.deleted_entities[key] = attr.evolve(
- deleted_entity,
- orphaned_timestamp=now_time,
- config_entry_id=None,
- disabled_by=disabled_by,
+ deleted_entity, orphaned_timestamp=now_time, config_entry_id=None
)
self.async_schedule_save()
@@ -1834,11 +1899,25 @@ def _async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) -
@callback
def cleanup_restored_states_filter(event_data: Mapping[str, Any]) -> bool:
"""Clean up restored states filter."""
- return bool(event_data["action"] == "remove")
+ return (event_data["action"] == "remove") or (
+ event_data["action"] == "update"
+ and "old_entity_id" in event_data
+ and event_data["entity_id"] != event_data["old_entity_id"]
+ )
@callback
def cleanup_restored_states(event: Event[EventEntityRegistryUpdatedData]) -> None:
"""Clean up restored states."""
+ if event.data["action"] == "update":
+ old_entity_id = event.data["old_entity_id"]
+ old_state = hass.states.get(old_entity_id)
+ if old_state is None or not old_state.attributes.get(ATTR_RESTORED):
+ return
+ hass.states.async_remove(old_entity_id, context=event.context)
+ if entry := registry.async_get(event.data["entity_id"]):
+ entry.write_unavailable_state(hass)
+ return
+
state = hass.states.get(event.data["entity_id"])
if state is None or not state.attributes.get(ATTR_RESTORED):
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 39cff22396a..8cadf4b7d4c 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -54,7 +54,8 @@ from .entity_registry import (
)
from .ratelimit import KeyedRateLimit
from .sun import get_astral_event_next
-from .template import RenderInfo, Template, result_as_boolean
+from .template import Template, result_as_boolean
+from .template.render_info import RenderInfo
from .typing import TemplateVarsType
_TRACK_STATE_CHANGE_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = HassKey(
diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py
index 186ad2b31f7..8578d85a3d3 100644
--- a/homeassistant/helpers/floor_registry.py
+++ b/homeassistant/helpers/floor_registry.py
@@ -105,8 +105,7 @@ class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]):
def _index_entry(self, key: str, entry: FloorEntry) -> None:
"""Index an entry."""
super()._index_entry(key, entry)
- for alias in entry.aliases:
- normalized_alias = normalize_name(alias)
+ for normalized_alias in {normalize_name(alias) for alias in entry.aliases}:
self._aliases_index[normalized_alias][key] = True
def _unindex_entry(
@@ -116,8 +115,7 @@ class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]):
super()._unindex_entry(key, replacement_entry)
entry = self.data[key]
if aliases := entry.aliases:
- for alias in aliases:
- normalized_alias = normalize_name(alias)
+ for normalized_alias in {normalize_name(alias) for alias in aliases}:
self._unindex_entry_value(key, normalized_alias, self._aliases_index)
def get_floors_for_alias(self, alias: str) -> list[FloorEntry]:
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index 75572194bb8..5b21c12d755 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -33,6 +33,7 @@ from . import (
entity_registry,
floor_registry,
)
+from .deprecation import EnumWithDeprecatedMembers
from .typing import VolSchemaType
_LOGGER = logging.getLogger(__name__)
@@ -114,6 +115,7 @@ async def async_handle(
language: str | None = None,
assistant: str | None = None,
device_id: str | None = None,
+ satellite_id: str | None = None,
conversation_agent_id: str | None = None,
) -> IntentResponse:
"""Handle an intent."""
@@ -138,6 +140,7 @@ async def async_handle(
language=language,
assistant=assistant,
device_id=device_id,
+ satellite_id=satellite_id,
conversation_agent_id=conversation_agent_id,
)
@@ -1264,22 +1267,11 @@ class ServiceIntentHandler(DynamicServiceIntentHandler):
return (self.domain, self.service)
-class IntentCategory(Enum):
- """Category of an intent."""
-
- ACTION = "action"
- """Trigger an action like turning an entity on or off"""
-
- QUERY = "query"
- """Get information about the state of an entity"""
-
-
class Intent:
"""Hold the intent."""
__slots__ = [
"assistant",
- "category",
"context",
"conversation_agent_id",
"device_id",
@@ -1287,6 +1279,7 @@ class Intent:
"intent_type",
"language",
"platform",
+ "satellite_id",
"slots",
"text_input",
]
@@ -1300,9 +1293,9 @@ class Intent:
text_input: str | None,
context: Context,
language: str,
- category: IntentCategory | None = None,
assistant: str | None = None,
device_id: str | None = None,
+ satellite_id: str | None = None,
conversation_agent_id: str | None = None,
) -> None:
"""Initialize an intent."""
@@ -1313,9 +1306,9 @@ class Intent:
self.text_input = text_input
self.context = context
self.language = language
- self.category = category
self.assistant = assistant
self.device_id = device_id
+ self.satellite_id = satellite_id
self.conversation_agent_id = conversation_agent_id
@callback
@@ -1324,14 +1317,23 @@ class Intent:
return IntentResponse(language=self.language, intent=self)
-class IntentResponseType(Enum):
+class IntentResponseType(
+ Enum,
+ metaclass=EnumWithDeprecatedMembers,
+ deprecated={
+ "PARTIAL_ACTION_DONE": (
+ "IntentResponseType.ACTION_DONE or IntentResponseType.ERROR",
+ "2026.3.0",
+ ),
+ },
+):
"""Type of the intent response."""
ACTION_DONE = "action_done"
"""Intent caused an action to occur"""
PARTIAL_ACTION_DONE = "partial_action_done"
- """Intent caused an action, but it could only be partially done"""
+ """Deprecated. Intent caused an action, but it could only be partially done"""
QUERY_ANSWER = "query_answer"
"""Response is an answer to a query"""
@@ -1398,12 +1400,7 @@ class IntentResponse:
self.matched_states: list[State] = []
self.unmatched_states: list[State] = []
self.speech_slots: dict[str, Any] = {}
-
- if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY):
- # speech will be the answer to the query
- self.response_type = IntentResponseType.QUERY_ANSWER
- else:
- self.response_type = IntentResponseType.ACTION_DONE
+ self.response_type = IntentResponseType.ACTION_DONE
@callback
def async_set_speech(
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index dc69916a728..1eb30fe7512 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -656,7 +656,6 @@ def _get_exposed_entities(
if not async_should_expose(hass, assistant, state.entity_id):
continue
- description: str | None = None
entity_entry = entity_registry.async_get(state.entity_id)
names = [state.name]
area_names = []
@@ -687,8 +686,10 @@ def _get_exposed_entities(
if include_state:
info["state"] = state.state
- if description:
- info["description"] = description
+ # Convert timestamp device_class states from UTC to local time
+ if state.attributes.get("device_class") == "timestamp" and state.state:
+ if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
+ info["state"] = dt_util.as_local(parsed_utc).isoformat()
if area_names:
info["areas"] = ", ".join(area_names)
@@ -828,7 +829,7 @@ def selector_serializer(schema: Any) -> Any: # noqa: C901
return {"type": "string", "enum": options}
if isinstance(schema, selector.TargetSelector):
- return convert(cv.TARGET_SERVICE_FIELDS)
+ return convert(cv.TARGET_FIELDS)
if isinstance(schema, selector.TemplateSelector):
return {"type": "string", "format": "jinja2"}
diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py
index 8bc773d85f7..69cfc8f8450 100644
--- a/homeassistant/helpers/schema_config_entry_flow.py
+++ b/homeassistant/helpers/schema_config_entry_flow.py
@@ -16,6 +16,7 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
+ OptionsFlowWithReload,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.data_entry_flow import UnknownHandler
@@ -107,7 +108,19 @@ class SchemaFlowMenuStep(SchemaFlowStep):
"""Define a config or options flow menu step."""
# Menu options
- options: Container[str]
+ options: (
+ Container[str]
+ | Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, Container[str]]]
+ )
+ """Menu options, or function which returns menu options.
+
+ - If a function is specified, the function will be passed the current
+ `SchemaCommonFlowHandler`.
+ """
+
+ sort: bool = False
+ """If true, menu options will be alphabetically sorted by the option label.
+ """
class SchemaCommonFlowHandler:
@@ -152,6 +165,11 @@ class SchemaCommonFlowHandler:
return await self._async_form_step(step_id, user_input)
return await self._async_menu_step(step_id, user_input)
+ async def _get_options(self, form_step: SchemaFlowMenuStep) -> Container[str]:
+ if isinstance(form_step.options, Container):
+ return form_step.options
+ return await form_step.options(self)
+
async def _get_schema(self, form_step: SchemaFlowFormStep) -> vol.Schema | None:
if form_step.schema is None:
return None
@@ -255,7 +273,8 @@ class SchemaCommonFlowHandler:
menu_step = cast(SchemaFlowMenuStep, self._flow[next_step_id])
return self._handler.async_show_menu(
step_id=next_step_id,
- menu_options=menu_step.options,
+ menu_options=await self._get_options(menu_step),
+ sort=menu_step.sort,
)
form_step = cast(SchemaFlowFormStep, self._flow[next_step_id])
@@ -308,7 +327,8 @@ class SchemaCommonFlowHandler:
menu_step: SchemaFlowMenuStep = cast(SchemaFlowMenuStep, self._flow[step_id])
return self._handler.async_show_menu(
step_id=step_id,
- menu_options=menu_step.options,
+ menu_options=await self._get_options(menu_step),
+ sort=menu_step.sort,
)
@@ -317,6 +337,7 @@ class SchemaConfigFlowHandler(ConfigFlow, ABC):
config_flow: Mapping[str, SchemaFlowStep]
options_flow: Mapping[str, SchemaFlowStep] | None = None
+ options_flow_reloads: bool = False
VERSION = 1
@@ -332,6 +353,13 @@ class SchemaConfigFlowHandler(ConfigFlow, ABC):
if cls.options_flow is None:
raise UnknownHandler
+ if cls.options_flow_reloads:
+ return SchemaOptionsFlowHandlerWithReload(
+ config_entry,
+ cls.options_flow,
+ cls.async_options_flow_finished,
+ cls.async_setup_preview,
+ )
return SchemaOptionsFlowHandler(
config_entry,
cls.options_flow,
@@ -485,6 +513,12 @@ class SchemaOptionsFlowHandler(OptionsFlow):
return super().async_create_entry(data=data, **kwargs)
+class SchemaOptionsFlowHandlerWithReload(
+ SchemaOptionsFlowHandler, OptionsFlowWithReload
+):
+ """Handle a schema based options flow which automatically reloads."""
+
+
@callback
def wrapped_entity_config_entry_title(
hass: HomeAssistant, entity_id_or_uuid: str
diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py
index 1003991ccec..474d5e71558 100644
--- a/homeassistant/helpers/selector.py
+++ b/homeassistant/helpers/selector.py
@@ -22,8 +22,8 @@ from . import config_validation as cv
SELECTORS: decorator.Registry[str, type[Selector]] = decorator.Registry()
-def _get_selector_class(config: Any) -> type[Selector]:
- """Get selector class type."""
+def _get_selector_type_and_class(config: Any) -> tuple[str, type[Selector]]:
+ """Get selector type and class."""
if not isinstance(config, dict):
raise vol.Invalid("Expected a dictionary")
@@ -35,29 +35,19 @@ def _get_selector_class(config: Any) -> type[Selector]:
if (selector_class := SELECTORS.get(selector_type)) is None:
raise vol.Invalid(f"Unknown selector type {selector_type} found")
- return selector_class
+ return selector_type, selector_class
def selector(config: Any) -> Selector:
"""Instantiate a selector."""
- selector_class = _get_selector_class(config)
- selector_type = list(config)[0]
-
+ selector_type, selector_class = _get_selector_type_and_class(config)
return selector_class(config[selector_type])
def validate_selector(config: Any) -> dict:
"""Validate a selector."""
- selector_class = _get_selector_class(config)
- selector_type = list(config)[0]
-
- # Selectors can be empty
- if config[selector_type] is None:
- return {selector_type: {}}
-
- return {
- selector_type: cast(dict, selector_class.CONFIG_SCHEMA(config[selector_type]))
- }
+ selector_type, selector_class = _get_selector_type_and_class(config)
+ return {selector_type: selector_class.CONFIG_SCHEMA(config[selector_type])}
class Selector[_T: Mapping[str, Any]]:
@@ -69,10 +59,6 @@ class Selector[_T: Mapping[str, Any]]:
def __init__(self, config: Mapping[str, Any] | None = None) -> None:
"""Instantiate a selector."""
- # Selectors can be empty
- if config is None:
- config = {}
-
self.config = self.CONFIG_SCHEMA(config)
def __eq__(self, other: object) -> bool:
@@ -128,11 +114,25 @@ def _validate_supported_features(supported_features: list[str]) -> int:
return feature_mask
-BASE_SELECTOR_CONFIG_SCHEMA = vol.Schema(
- {
- vol.Optional("read_only"): bool,
- }
-)
+def make_selector_config_schema(schema_dict: dict | None = None) -> vol.Schema:
+ """Make selector config schema."""
+ if schema_dict is None:
+ schema_dict = {}
+
+ def none_to_empty_dict(value: Any) -> Any:
+ if value is None:
+ return {}
+ return value
+
+ return vol.Schema(
+ vol.All(
+ none_to_empty_dict,
+ {
+ vol.Optional("read_only"): bool,
+ **schema_dict,
+ },
+ )
+ )
class BaseSelectorConfig(TypedDict, total=False):
@@ -161,16 +161,14 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
# is provided for backwards compatibility and remains feature frozen.
# New filtering features should be added under the `filter` key instead.
# https://github.com/home-assistant/frontend/pull/15302
-LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema(
- {
- # Integration that provided the entity
- vol.Optional("integration"): str,
- # Domain the entity belongs to
- vol.Optional("domain"): vol.All(cv.ensure_list, [str]),
- # Device class of the entity
- vol.Optional("device_class"): vol.All(cv.ensure_list, [str]),
- }
-)
+_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT = {
+ # Integration that provided the entity
+ vol.Optional("integration"): str,
+ # Domain the entity belongs to
+ vol.Optional("domain"): vol.All(cv.ensure_list, [str]),
+ # Device class of the entity
+ vol.Optional("device_class"): vol.All(cv.ensure_list, [str]),
+}
class EntityFilterSelectorConfig(TypedDict, total=False):
@@ -200,16 +198,14 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
# is provided for backwards compatibility and remains feature frozen.
# New filtering features should be added under the `filter` key instead.
# https://github.com/home-assistant/frontend/pull/15302
-LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema(
- {
- # Integration linked to it with a config entry
- vol.Optional("integration"): str,
- # Manufacturer of device
- vol.Optional("manufacturer"): str,
- # Model of device
- vol.Optional("model"): str,
- }
-)
+_LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA_DICT = {
+ # Integration linked to it with a config entry
+ vol.Optional("integration"): str,
+ # Manufacturer of device
+ vol.Optional("manufacturer"): str,
+ # Model of device
+ vol.Optional("model"): str,
+}
class DeviceFilterSelectorConfig(TypedDict, total=False):
@@ -231,7 +227,7 @@ class ActionSelector(Selector[ActionSelectorConfig]):
selector_type = "action"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: ActionSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -255,7 +251,7 @@ class AddonSelector(Selector[AddonSelectorConfig]):
selector_type = "addon"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("name"): str,
vol.Optional("slug"): str,
@@ -286,7 +282,7 @@ class AreaSelector(Selector[AreaSelectorConfig]):
selector_type = "area"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("entity"): vol.All(
cv.ensure_list,
@@ -324,7 +320,7 @@ class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]):
selector_type = "assist_pipeline"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: AssistPipelineSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -349,7 +345,7 @@ class AttributeSelector(Selector[AttributeSelectorConfig]):
selector_type = "attribute"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Required("entity_id"): cv.entity_id,
# hide_attributes is used to hide attributes in the frontend.
@@ -378,7 +374,7 @@ class BackupLocationSelector(Selector[BackupLocationSelectorConfig]):
selector_type = "backup_location"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -400,7 +396,7 @@ class BooleanSelector(Selector[BooleanSelectorConfig]):
selector_type = "boolean"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: BooleanSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -422,7 +418,7 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]):
selector_type = "color_rgb"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: ColorRGBSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -457,7 +453,7 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]):
selector_type = "color_temp"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All(
vol.Coerce(ColorTempSelectorUnit), lambda val: val.value
@@ -504,7 +500,7 @@ class ConditionSelector(Selector[ConditionSelectorConfig]):
selector_type = "condition"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: ConditionSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -527,7 +523,7 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]):
selector_type = "config_entry"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("integration"): str,
}
@@ -557,7 +553,7 @@ class ConstantSelector(Selector[ConstantSelectorConfig]):
selector_type = "constant"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("label"): str,
vol.Optional("translation_key"): cv.string,
@@ -587,7 +583,7 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]):
selector_type = "conversation_agent"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("language"): str,
}
@@ -616,7 +612,7 @@ class CountrySelector(Selector[CountrySelectorConfig]):
selector_type = "country"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("countries"): [str],
vol.Optional("no_sort", default=False): cv.boolean,
@@ -647,7 +643,7 @@ class DateSelector(Selector[DateSelectorConfig]):
selector_type = "date"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: DateSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -669,7 +665,7 @@ class DateTimeSelector(Selector[DateTimeSelectorConfig]):
selector_type = "datetime"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: DateTimeSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -695,10 +691,9 @@ class DeviceSelector(Selector[DeviceSelectorConfig]):
selector_type = "device"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
- LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema
- ).extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
+ **_LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA_DICT,
# Device has to contain entities matching this selector
vol.Optional("entity"): vol.All(
cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA]
@@ -739,7 +734,7 @@ class DurationSelector(Selector[DurationSelectorConfig]):
selector_type = "duration"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
# Enable day field in frontend. A selection with `days` set is allowed
# even if `enable_day` is not set
@@ -780,10 +775,9 @@ class EntitySelector(Selector[EntitySelectorConfig]):
selector_type = "entity"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
- LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema
- ).extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
+ **_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT,
vol.Optional("exclude_entities"): [str],
vol.Optional("include_entities"): [str],
vol.Optional("multiple", default=False): cv.boolean,
@@ -841,7 +835,7 @@ class FileSelector(Selector[FileSelectorConfig]):
selector_type = "file"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
# https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
vol.Required("accept"): str,
@@ -876,7 +870,7 @@ class FloorSelector(Selector[FloorSelectorConfig]):
selector_type = "floor"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("entity"): vol.All(
cv.ensure_list,
@@ -916,7 +910,7 @@ class IconSelector(Selector[IconSelectorConfig]):
selector_type = "icon"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{vol.Optional("placeholder"): str}
# Frontend also has a fallbackPath option, this is not used by core
)
@@ -943,7 +937,7 @@ class LabelSelector(Selector[LabelSelectorConfig]):
selector_type = "label"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("multiple", default=False): cv.boolean,
}
@@ -977,7 +971,7 @@ class LanguageSelector(Selector[LanguageSelectorConfig]):
selector_type = "language"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("languages"): [str],
vol.Optional("native_name", default=False): cv.boolean,
@@ -1010,7 +1004,7 @@ class LocationSelector(Selector[LocationSelectorConfig]):
selector_type = "location"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{vol.Optional("radius"): bool, vol.Optional("icon"): str}
)
DATA_SCHEMA = vol.Schema(
@@ -1043,7 +1037,7 @@ class MediaSelector(Selector[MediaSelectorConfig]):
selector_type = "media"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("accept"): [str],
}
@@ -1118,7 +1112,7 @@ class NumberSelector(Selector[NumberSelectorConfig]):
selector_type = "number"
CONFIG_SCHEMA = vol.All(
- BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ make_selector_config_schema(
{
vol.Optional("min"): vol.Coerce(float),
vol.Optional("max"): vol.Coerce(float),
@@ -1168,7 +1162,7 @@ class ObjectSelectorConfig(BaseSelectorConfig):
fields: dict[str, ObjectSelectorField]
multiple: bool
label_field: str
- description_field: bool
+ description_field: str
translation_key: str
@@ -1178,7 +1172,7 @@ class ObjectSelector(Selector[ObjectSelectorConfig]):
selector_type = "object"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("fields"): {
str: {
@@ -1226,7 +1220,7 @@ class QrCodeSelector(Selector[QrCodeSelectorConfig]):
selector_type = "qr_code"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Required("data"): str,
vol.Optional("scale"): int,
@@ -1288,7 +1282,7 @@ class SelectSelector(Selector[SelectSelectorConfig]):
selector_type = "select"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Required("options"): vol.All(vol.Any([str], [select_option])),
vol.Optional("multiple", default=False): cv.boolean,
@@ -1342,7 +1336,7 @@ class StateSelector(Selector[StateSelectorConfig]):
selector_type = "state"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("entity_id"): cv.entity_id,
vol.Optional("hide_states"): [str],
@@ -1381,7 +1375,7 @@ class StatisticSelector(Selector[StatisticSelectorConfig]):
selector_type = "statistic"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("multiple", default=False): cv.boolean,
}
@@ -1418,7 +1412,7 @@ class TargetSelector(Selector[TargetSelectorConfig]):
selector_type = "target"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("entity"): vol.All(
cv.ensure_list,
@@ -1453,7 +1447,7 @@ class TemplateSelector(Selector[TemplateSelectorConfig]):
selector_type = "template"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: TemplateSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -1500,7 +1494,7 @@ class TextSelector(Selector[TextSelectorConfig]):
selector_type = "text"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("multiline", default=False): bool,
vol.Optional("prefix"): str,
@@ -1539,7 +1533,7 @@ class ThemeSelector(Selector[ThemeSelectorConfig]):
selector_type = "theme"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
+ CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("include_default", default=False): cv.boolean,
}
@@ -1565,7 +1559,7 @@ class TimeSelector(Selector[TimeSelectorConfig]):
selector_type = "time"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: TimeSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
@@ -1587,7 +1581,7 @@ class TriggerSelector(Selector[TriggerSelectorConfig]):
selector_type = "trigger"
- CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA
+ CONFIG_SCHEMA = make_selector_config_schema()
def __init__(self, config: TriggerSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index f9c846c60fa..189abd6474d 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -60,7 +60,7 @@ from . import (
template,
translation,
)
-from .deprecation import deprecated_class, deprecated_function
+from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument
from .selector import TargetSelector
from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType
@@ -190,7 +190,7 @@ _SECTION_SCHEMA = vol.Schema(
_SERVICE_SCHEMA = vol.Schema(
{
- vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None),
+ vol.Optional("target"): TargetSelector.CONFIG_SCHEMA,
vol.Optional("fields"): vol.Schema(
{str: vol.Any(_SECTION_SCHEMA, _FIELD_SCHEMA)}
),
@@ -379,22 +379,21 @@ def async_prepare_call_from_config(
}
-@bind_hass
+@deprecated_hass_argument(breaks_in_ha_version="2026.10")
def extract_entity_ids(
- hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
+ service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
"""Extract a list of entity ids from a service call.
Will convert group entity ids to the entity ids it represents.
"""
return asyncio.run_coroutine_threadsafe(
- async_extract_entity_ids(hass, service_call, expand_group), hass.loop
+ async_extract_entity_ids(service_call, expand_group), service_call.hass.loop
).result()
-@bind_hass
+@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_entities[_EntityT: Entity](
- hass: HomeAssistant,
entities: Iterable[_EntityT],
service_call: ServiceCall,
expand_group: bool = True,
@@ -410,7 +409,7 @@ async def async_extract_entities[_EntityT: Entity](
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
- hass, selector_data, expand_group
+ service_call.hass, selector_data, expand_group
)
combined = referenced.referenced | referenced.indirectly_referenced
@@ -432,9 +431,9 @@ async def async_extract_entities[_EntityT: Entity](
return found
-@bind_hass
+@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_entity_ids(
- hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
+ service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
"""Extract a set of entity ids from a service call.
@@ -442,7 +441,7 @@ async def async_extract_entity_ids(
"""
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
- hass, selector_data, expand_group
+ service_call.hass, selector_data, expand_group
)
return referenced.referenced | referenced.indirectly_referenced
@@ -463,17 +462,17 @@ def async_extract_referenced_entity_ids(
return SelectedEntities(**dataclasses.asdict(selected))
-@bind_hass
+@deprecated_hass_argument(breaks_in_ha_version="2026.10")
async def async_extract_config_entry_ids(
- hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True
+ service_call: ServiceCall, expand_group: bool = True
) -> set[str]:
"""Extract referenced config entry ids from a service call."""
selector_data = target_helpers.TargetSelectorData(service_call.data)
referenced = target_helpers.async_extract_referenced_entity_ids(
- hass, selector_data, expand_group
+ service_call.hass, selector_data, expand_group
)
- ent_reg = entity_registry.async_get(hass)
- dev_reg = device_registry.async_get(hass)
+ ent_reg = entity_registry.async_get(service_call.hass)
+ dev_reg = device_registry.async_get(service_call.hass)
config_entry_ids: set[str] = set()
# Some devices may have no entities
@@ -492,7 +491,7 @@ async def async_extract_config_entry_ids(
return config_entry_ids
-def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
+def _load_services_file(integration: Integration) -> JSON_TYPE:
"""Load services file for an integration."""
try:
return cast(
@@ -515,12 +514,10 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T
return {}
-def _load_services_files(
- hass: HomeAssistant, integrations: Iterable[Integration]
-) -> dict[str, JSON_TYPE]:
+def _load_services_files(integrations: Iterable[Integration]) -> dict[str, JSON_TYPE]:
"""Load service files for multiple integrations."""
return {
- integration.domain: _load_services_file(hass, integration)
+ integration.domain: _load_services_file(integration)
for integration in integrations
}
@@ -586,7 +583,7 @@ async def async_get_all_descriptions(
if integrations:
loaded = await hass.async_add_executor_job(
- _load_services_files, hass, integrations
+ _load_services_files, integrations
)
# Load translations for all service domains
@@ -760,10 +757,12 @@ def _get_permissible_entity_candidates(
@bind_hass
async def entity_service_call(
hass: HomeAssistant,
- registered_entities: dict[str, Entity],
+ registered_entities: dict[str, Entity] | Callable[[], dict[str, Entity]],
func: str | HassJob,
call: ServiceCall,
required_features: Iterable[int] | None = None,
+ *,
+ entity_device_classes: Iterable[str | None] | None = None,
) -> EntityServiceResponse | None:
"""Handle an entity service call.
@@ -799,10 +798,15 @@ async def entity_service_call(
else:
data = call
+ if callable(registered_entities):
+ _registered_entities = registered_entities()
+ else:
+ _registered_entities = registered_entities
+
# A list with entities to call the service on.
entity_candidates = _get_permissible_entity_candidates(
call,
- registered_entities,
+ _registered_entities,
entity_perms,
target_all_entities,
all_referenced,
@@ -821,6 +825,17 @@ async def entity_service_call(
if not entity.available:
continue
+ # Skip entities that don't have the required device class.
+ if (
+ entity_device_classes is not None
+ and entity.device_class not in entity_device_classes
+ ):
+ # If entity explicitly referenced, raise an error
+ if referenced is not None and entity.entity_id in referenced.referenced:
+ raise ServiceNotSupported(call.domain, call.service, entity.entity_id)
+
+ continue
+
# Skip entities that don't have the required feature.
if required_features is not None and (
entity.supported_features is None
@@ -990,10 +1005,10 @@ def async_register_admin_service(
)
-@bind_hass
+@deprecated_hass_argument(breaks_in_ha_version="2026.10")
@callback
def verify_domain_control(
- hass: HomeAssistant, domain: str
+ domain: str,
) -> Callable[[Callable[[ServiceCall], Any]], Callable[[ServiceCall], Any]]:
"""Ensure permission to access any entity under domain in service call."""
@@ -1009,6 +1024,7 @@ def verify_domain_control(
if not call.context.user_id:
return await service_handler(call)
+ hass = call.hass
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
@@ -1112,12 +1128,26 @@ class ReloadServiceHelper[_T]:
self._service_condition.notify_all()
+def _validate_entity_service_schema(
+ schema: VolDictType | VolSchemaType | None, service: str
+) -> VolSchemaType:
+ """Validate that a schema is an entity service schema."""
+ if schema is None or isinstance(schema, dict):
+ return cv.make_entity_service_schema(schema)
+ if not cv.is_entity_service_schema(schema):
+ raise HomeAssistantError(
+ f"The {service} service registers an entity service with a non entity service schema"
+ )
+ return schema
+
+
@callback
def async_register_entity_service(
hass: HomeAssistant,
domain: str,
name: str,
*,
+ entity_device_classes: Iterable[str | None] | None = None,
entities: dict[str, Entity],
func: str | Callable[..., Any],
job_type: HassJobType | None,
@@ -1131,16 +1161,7 @@ def async_register_entity_service(
EntityPlatform.async_register_entity_service and should not be called
directly by integrations.
"""
- if schema is None or isinstance(schema, dict):
- schema = cv.make_entity_service_schema(schema)
- elif not cv.is_entity_service_schema(schema):
- from .frame import ReportBehavior, report_usage # noqa: PLC0415
-
- report_usage(
- "registers an entity service with a non entity service schema",
- core_behavior=ReportBehavior.LOG,
- breaks_in_ha_version="2025.9",
- )
+ schema = _validate_entity_service_schema(schema, f"{domain}.{name}")
service_func: str | HassJob[..., Any]
service_func = func if isinstance(func, str) else HassJob(func)
@@ -1153,9 +1174,56 @@ def async_register_entity_service(
hass,
entities,
service_func,
+ entity_device_classes=entity_device_classes,
required_features=required_features,
),
schema,
supports_response,
job_type=job_type,
)
+
+
+@callback
+def async_register_platform_entity_service(
+ hass: HomeAssistant,
+ service_domain: str,
+ service_name: str,
+ *,
+ entity_device_classes: Iterable[str | None] | None = None,
+ entity_domain: str,
+ func: str | Callable[..., Any],
+ required_features: Iterable[int] | None = None,
+ schema: VolDictType | VolSchemaType | None,
+ supports_response: SupportsResponse = SupportsResponse.NONE,
+) -> None:
+ """Help registering a platform entity service."""
+ from .entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES # noqa: PLC0415
+
+ schema = _validate_entity_service_schema(schema, f"{service_domain}.{service_name}")
+
+ service_func: str | HassJob[..., Any]
+ service_func = func if isinstance(func, str) else HassJob(func)
+
+ def get_entities() -> dict[str, Entity]:
+ entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get(
+ (entity_domain, service_domain)
+ )
+ if entities is None:
+ return {}
+ return entities
+
+ hass.services.async_register(
+ service_domain,
+ service_name,
+ partial(
+ entity_service_call,
+ hass,
+ get_entities,
+ service_func,
+ entity_device_classes=entity_device_classes,
+ required_features=required_features,
+ ),
+ schema,
+ supports_response,
+ job_type=HassJobType.Coroutinefunction,
+ )
diff --git a/homeassistant/helpers/service_info/esphome.py b/homeassistant/helpers/service_info/esphome.py
new file mode 100644
index 00000000000..5a9d50baaec
--- /dev/null
+++ b/homeassistant/helpers/service_info/esphome.py
@@ -0,0 +1,26 @@
+"""ESPHome discovery data."""
+
+from dataclasses import dataclass
+
+from yarl import URL
+
+from homeassistant.data_entry_flow import BaseServiceInfo
+
+
+@dataclass(slots=True)
+class ESPHomeServiceInfo(BaseServiceInfo):
+ """Prepared info from ESPHome entries."""
+
+ name: str
+ zwave_home_id: int | None
+ ip_address: str
+ port: int
+ noise_psk: str | None = None
+
+ @property
+ def socket_path(self) -> str:
+ """Return the socket path to connect to the ESPHome device."""
+ url = URL.build(scheme="esphome", host=self.ip_address, port=self.port)
+ if self.noise_psk:
+ url = url.with_user(self.noise_psk)
+ return str(url)
diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py
index 5286daaeef0..79e84a2dccf 100644
--- a/homeassistant/helpers/target.py
+++ b/homeassistant/helpers/target.py
@@ -96,10 +96,10 @@ class TargetSelectorData:
class SelectedEntities:
"""Class to hold the selected entities."""
- # Entities that were explicitly mentioned.
+ # Entity IDs of entities that were explicitly mentioned.
referenced: set[str] = dataclasses.field(default_factory=set)
- # Entities that were referenced via device/area/floor/label ID.
+ # Entity IDs of entities that were referenced via device/area/floor/label ID.
# Should not trigger a warning when they don't exist.
indirectly_referenced: set[str] = dataclasses.field(default_factory=set)
@@ -182,10 +182,7 @@ def async_extract_referenced_entity_ids(
selected.missing_labels.add(label_id)
for entity_entry in entities.get_entries_for_label(label_id):
- if (
- entity_entry.entity_category is None
- and entity_entry.hidden_by is None
- ):
+ if entity_entry.hidden_by is None:
selected.indirectly_referenced.add(entity_entry.entity_id)
for device_entry in dev_reg.devices.get_devices_for_label(label_id):
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template/__init__.py
similarity index 75%
rename from homeassistant/helpers/template.py
rename to homeassistant/helpers/template/__init__.py
index 8e3106093aa..34c3955dbdd 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template/__init__.py
@@ -4,15 +4,11 @@ from __future__ import annotations
from ast import literal_eval
import asyncio
-import base64
import collections.abc
-from collections.abc import Callable, Generator, Iterable, MutableSequence
-from contextlib import AbstractContextManager
-from contextvars import ContextVar
+from collections.abc import Callable, Generator, Iterable
from copy import deepcopy
from datetime import date, datetime, time, timedelta
from functools import cache, lru_cache, partial, wraps
-import hashlib
import json
import logging
import math
@@ -20,26 +16,15 @@ from operator import contains
import pathlib
import random
import re
-import statistics
from struct import error as StructError, pack, unpack_from
import sys
-from types import CodeType, TracebackType
-from typing import (
- TYPE_CHECKING,
- Any,
- Concatenate,
- Literal,
- NoReturn,
- Self,
- cast,
- overload,
-)
-from urllib.parse import urlencode as urllib_urlencode
+from types import CodeType
+from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload
import weakref
from awesomeversion import AwesomeVersion
import jinja2
-from jinja2 import pass_context, pass_environment, pass_eval_context
+from jinja2 import pass_context, pass_eval_context
from jinja2.runtime import AsyncLoopContext, LoopContext
from jinja2.sandbox import ImmutableSandboxedEnvironment
from jinja2.utils import Namespace
@@ -66,37 +51,37 @@ from homeassistant.core import (
ServiceResponse,
State,
callback,
- split_entity_id,
valid_domain,
valid_entity_id,
)
from homeassistant.exceptions import TemplateError
-from homeassistant.loader import bind_hass
-from homeassistant.util import (
- convert,
- dt as dt_util,
- location as location_util,
- slugify as slugify_util,
+from homeassistant.helpers import (
+ area_registry as ar,
+ device_registry as dr,
+ entity_registry as er,
+ floor_registry as fr,
+ issue_registry as ir,
+ label_registry as lr,
+ location as loc_helper,
)
+from homeassistant.helpers.singleton import singleton
+from homeassistant.helpers.translation import async_translate_state
+from homeassistant.helpers.typing import TemplateVarsType
+from homeassistant.util import convert, dt as dt_util, location as location_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from homeassistant.util.read_only_dict import ReadOnlyDict
from homeassistant.util.thread import ThreadWithException
-from . import (
- area_registry,
- device_registry,
- entity_registry,
- floor_registry as fr,
- issue_registry,
- label_registry,
- location as loc_helper,
+from .context import (
+ TemplateContextManager as TemplateContextManager,
+ render_with_context,
+ template_context_manager,
+ template_cv,
)
-from .deprecation import deprecated_function
-from .singleton import singleton
-from .translation import async_translate_state
-from .typing import TemplateVarsType
+from .helpers import raise_no_default
+from .render_info import RenderInfo, render_info_cv
if TYPE_CHECKING:
from _typeshed import OptExcInfo
@@ -137,15 +122,6 @@ _COLLECTABLE_STATE_ATTRIBUTES = {
"name",
}
-ALL_STATES_RATE_LIMIT = 60 # seconds
-DOMAIN_STATES_RATE_LIMIT = 1 # seconds
-
-_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None)
-
-
-template_cv: ContextVar[tuple[str, str] | None] = ContextVar(
- "template_cv", default=None
-)
#
# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities
@@ -210,7 +186,7 @@ def async_setup(hass: HomeAssistant) -> bool:
if new_size > current_size:
lru.set_size(new_size)
- from .event import async_track_time_interval # noqa: PLC0415
+ from homeassistant.helpers.event import async_track_time_interval # noqa: PLC0415
cancel = async_track_time_interval(
hass, _async_adjust_lru_sizes, timedelta(minutes=10)
@@ -220,29 +196,6 @@ def async_setup(hass: HomeAssistant) -> bool:
return True
-@bind_hass
-@deprecated_function(
- "automatic setting of Template.hass introduced by HA Core PR #89242",
- breaks_in_ha_version="2025.10",
-)
-def attach(hass: HomeAssistant, obj: Any) -> None:
- """Recursively attach hass to all template instances in list and dict."""
- return _attach(hass, obj)
-
-
-def _attach(hass: HomeAssistant, obj: Any) -> None:
- """Recursively attach hass to all template instances in list and dict."""
- if isinstance(obj, list):
- for child in obj:
- _attach(hass, child)
- elif isinstance(obj, collections.abc.Mapping):
- for child_key, child_value in obj.items():
- _attach(hass, child_key)
- _attach(hass, child_value)
- elif isinstance(obj, Template):
- obj.hass = hass
-
-
def render_complex(
value: Any,
variables: TemplateVarsType = None,
@@ -344,14 +297,6 @@ RESULT_WRAPPERS: dict[type, type] = {kls: gen_result_wrapper(kls) for kls in _ty
RESULT_WRAPPERS[tuple] = TupleWrapper
-def _true(arg: str) -> bool:
- return True
-
-
-def _false(arg: str) -> bool:
- return False
-
-
@lru_cache(maxsize=EVAL_CACHE_SIZE)
def _cached_parse_result(render_result: str) -> Any:
"""Parse a result and cache the result."""
@@ -381,126 +326,6 @@ def _cached_parse_result(render_result: str) -> Any:
return render_result
-class RenderInfo:
- """Holds information about a template render."""
-
- __slots__ = (
- "_result",
- "all_states",
- "all_states_lifecycle",
- "domains",
- "domains_lifecycle",
- "entities",
- "exception",
- "filter",
- "filter_lifecycle",
- "has_time",
- "is_static",
- "rate_limit",
- "template",
- )
-
- def __init__(self, template: Template) -> None:
- """Initialise."""
- self.template = template
- # Will be set sensibly once frozen.
- self.filter_lifecycle: Callable[[str], bool] = _true
- self.filter: Callable[[str], bool] = _true
- self._result: str | None = None
- self.is_static = False
- self.exception: TemplateError | None = None
- self.all_states = False
- self.all_states_lifecycle = False
- self.domains: collections.abc.Set[str] = set()
- self.domains_lifecycle: collections.abc.Set[str] = set()
- self.entities: collections.abc.Set[str] = set()
- self.rate_limit: float | None = None
- self.has_time = False
-
- def __repr__(self) -> str:
- """Representation of RenderInfo."""
- return (
- f""
- )
-
- def _filter_domains_and_entities(self, entity_id: str) -> bool:
- """Template should re-render if the entity state changes.
-
- Only when we match specific domains or entities.
- """
- return (
- split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities
- )
-
- def _filter_entities(self, entity_id: str) -> bool:
- """Template should re-render if the entity state changes.
-
- Only when we match specific entities.
- """
- return entity_id in self.entities
-
- def _filter_lifecycle_domains(self, entity_id: str) -> bool:
- """Template should re-render if the entity is added or removed.
-
- Only with domains watched.
- """
- return split_entity_id(entity_id)[0] in self.domains_lifecycle
-
- def result(self) -> str:
- """Results of the template computation."""
- if self.exception is not None:
- raise self.exception
- return cast(str, self._result)
-
- def _freeze_static(self) -> None:
- self.is_static = True
- self._freeze_sets()
- self.all_states = False
-
- def _freeze_sets(self) -> None:
- self.entities = frozenset(self.entities)
- self.domains = frozenset(self.domains)
- self.domains_lifecycle = frozenset(self.domains_lifecycle)
-
- def _freeze(self) -> None:
- self._freeze_sets()
-
- if self.rate_limit is None:
- if self.all_states or self.exception:
- self.rate_limit = ALL_STATES_RATE_LIMIT
- elif self.domains or self.domains_lifecycle:
- self.rate_limit = DOMAIN_STATES_RATE_LIMIT
-
- if self.exception:
- return
-
- if not self.all_states_lifecycle:
- if self.domains_lifecycle:
- self.filter_lifecycle = self._filter_lifecycle_domains
- else:
- self.filter_lifecycle = _false
-
- if self.all_states:
- return
-
- if self.domains:
- self.filter = self._filter_domains_and_entities
- elif self.entities:
- self.filter = self._filter_entities
- else:
- self.filter = _false
-
-
class Template:
"""Class to hold a template and manage caching and rendering."""
@@ -525,7 +350,10 @@ class Template:
Note: A valid hass instance should always be passed in. The hass parameter
will be non optional in Home Assistant Core 2025.10.
"""
- from .frame import ReportBehavior, report_usage # noqa: PLC0415
+ from homeassistant.helpers.frame import ( # noqa: PLC0415
+ ReportBehavior,
+ report_usage,
+ )
if not isinstance(template, str):
raise TypeError("Expected template to be a string")
@@ -579,7 +407,7 @@ class Template:
self._compiled_code = compiled
return
- with _template_context_manager as cm:
+ with template_context_manager as cm:
cm.set_template(self.template, "compiling")
try:
self._compiled_code = self._env.compile(self.template)
@@ -638,7 +466,7 @@ class Template:
kwargs.update(variables)
try:
- render_result = _render_with_context(self.template, compiled, **kwargs)
+ render_result = render_with_context(self.template, compiled, **kwargs)
except Exception as err:
raise TemplateError(err) from err
@@ -700,7 +528,7 @@ class Template:
def _render_template() -> None:
assert self.hass is not None, "hass variable not set on template"
try:
- _render_with_context(self.template, compiled, **kwargs)
+ render_with_context(self.template, compiled, **kwargs)
except TimeoutError:
pass
except Exception: # noqa: BLE001
@@ -708,15 +536,16 @@ class Template:
finally:
self.hass.loop.call_soon_threadsafe(finish_event.set)
+ template_render_thread = ThreadWithException(target=_render_template)
try:
- template_render_thread = ThreadWithException(target=_render_template)
template_render_thread.start()
async with asyncio.timeout(timeout):
await finish_event.wait()
if self._exc_info:
raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2]))
except TimeoutError:
- template_render_thread.raise_exc(TimeoutError)
+ if template_render_thread.is_alive():
+ template_render_thread.raise_exc(TimeoutError)
return True
finally:
template_render_thread.join()
@@ -741,7 +570,7 @@ class Template:
if not self.hass:
raise RuntimeError(f"hass not set while rendering {self}")
- if _render_info.get() is not None:
+ if render_info_cv.get() is not None:
raise RuntimeError(
f"RenderInfo already set while rendering {self}, "
"this usually indicates the template is being rendered "
@@ -753,7 +582,7 @@ class Template:
render_info._freeze_static() # noqa: SLF001
return render_info
- token = _render_info.set(render_info)
+ token = render_info_cv.set(render_info)
try:
render_info._result = self.async_render( # noqa: SLF001
variables, strict=strict, log_fn=log_fn, **kwargs
@@ -761,7 +590,7 @@ class Template:
except TemplateError as ex:
render_info.exception = ex
finally:
- _render_info.reset(token)
+ render_info_cv.reset(token)
render_info._freeze() # noqa: SLF001
return render_info
@@ -811,7 +640,7 @@ class Template:
pass
try:
- render_result = _render_with_context(
+ render_result = render_with_context(
self.template, compiled, **variables
).strip()
except jinja2.TemplateError as ex:
@@ -918,11 +747,11 @@ class AllStates:
__getitem__ = __getattr__
def _collect_all(self) -> None:
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.all_states = True
def _collect_all_lifecycle(self) -> None:
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.all_states_lifecycle = True
def __iter__(self) -> Generator[TemplateState]:
@@ -973,7 +802,7 @@ class StateTranslated:
state_value = state.state
domain = state.domain
device_class = state.attributes.get("device_class")
- entry = entity_registry.async_get(self._hass).async_get(entity_id)
+ entry = er.async_get(self._hass).async_get(entity_id)
platform = None if entry is None else entry.platform
translation_key = None if entry is None else entry.translation_key
@@ -1008,11 +837,11 @@ class DomainStates:
__getitem__ = __getattr__
def _collect_domain(self) -> None:
- if (entity_collect := _render_info.get()) is not None:
+ if (entity_collect := render_info_cv.get()) is not None:
entity_collect.domains.add(self._domain) # type: ignore[attr-defined]
def _collect_domain_lifecycle(self) -> None:
- if (entity_collect := _render_info.get()) is not None:
+ if (entity_collect := render_info_cv.get()) is not None:
entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined]
def __iter__(self) -> Generator[TemplateState]:
@@ -1050,7 +879,7 @@ class TemplateStateBase(State):
self._cache: dict[str, Any] = {}
def _collect_state(self) -> None:
- if self._collect and (render_info := _render_info.get()):
+ if self._collect and (render_info := render_info_cv.get()):
render_info.entities.add(self._entity_id) # type: ignore[attr-defined]
# Jinja will try __getitem__ first and it avoids the need
@@ -1059,7 +888,7 @@ class TemplateStateBase(State):
"""Return a property as an attribute for jinja."""
if item in _COLLECTABLE_STATE_ATTRIBUTES:
# _collect_state inlined here for performance
- if self._collect and (render_info := _render_info.get()):
+ if self._collect and (render_info := render_info_cv.get()):
render_info.entities.add(self._entity_id) # type: ignore[attr-defined]
return getattr(self._state, item)
if item == "entity_id":
@@ -1201,7 +1030,7 @@ _create_template_state_no_collect = partial(TemplateState, collect=False)
def _collect_state(hass: HomeAssistant, entity_id: str) -> None:
- if (entity_collect := _render_info.get()) is not None:
+ if (entity_collect := render_info_cv.get()) is not None:
entity_collect.entities.add(entity_id) # type: ignore[attr-defined]
@@ -1274,7 +1103,7 @@ def forgiving_boolean[_T](
"""Try to convert value to a boolean."""
try:
# Import here, not at top-level to avoid circular import
- from . import config_validation as cv # noqa: PLC0415
+ from homeassistant.helpers import config_validation as cv # noqa: PLC0415
return cv.boolean(value)
except vol.Invalid:
@@ -1299,7 +1128,7 @@ def result_as_boolean(template_result: Any | None) -> bool:
def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]:
"""Expand out any groups and zones into entity states."""
# circular import.
- from . import entity as entity_helper # noqa: PLC0415
+ from homeassistant.helpers import entity as entity_helper # noqa: PLC0415
search = list(args)
found = {}
@@ -1341,8 +1170,8 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]:
def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]:
"""Get entity ids for entities tied to a device."""
- entity_reg = entity_registry.async_get(hass)
- entries = entity_registry.async_entries_for_device(entity_reg, _device_id)
+ entity_reg = er.async_get(hass)
+ entries = er.async_entries_for_device(entity_reg, _device_id)
return [entry.entity_id for entry in entries]
@@ -1360,19 +1189,17 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]:
# first try if there are any config entries with a matching title
entities: list[str] = []
- ent_reg = entity_registry.async_get(hass)
+ ent_reg = er.async_get(hass)
for entry in hass.config_entries.async_entries():
if entry.title != entry_name:
continue
- entries = entity_registry.async_entries_for_config_entry(
- ent_reg, entry.entry_id
- )
+ entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id)
entities.extend(entry.entity_id for entry in entries)
if entities:
return entities
# fallback to just returning all entities for a domain
- from .entity import entity_sources # noqa: PLC0415
+ from homeassistant.helpers.entity import entity_sources # noqa: PLC0415
return [
entity_id
@@ -1383,7 +1210,7 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]:
def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None:
"""Get an config entry ID from an entity ID."""
- entity_reg = entity_registry.async_get(hass)
+ entity_reg = er.async_get(hass)
if entity := entity_reg.async_get(entity_id):
return entity.config_entry_id
return None
@@ -1391,12 +1218,12 @@ def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None:
def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None:
"""Get a device ID from an entity ID or device name."""
- entity_reg = entity_registry.async_get(hass)
+ entity_reg = er.async_get(hass)
entity = entity_reg.async_get(entity_id_or_device_name)
if entity is not None:
return entity.device_id
- dev_reg = device_registry.async_get(hass)
+ dev_reg = dr.async_get(hass)
return next(
(
device_id
@@ -1410,13 +1237,13 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None:
def device_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the device name from an device id, or entity id."""
- device_reg = device_registry.async_get(hass)
+ device_reg = dr.async_get(hass)
if device := device_reg.async_get(lookup_value):
return device.name_by_user or device.name
- ent_reg = entity_registry.async_get(hass)
+ ent_reg = er.async_get(hass)
# Import here, not at top-level to avoid circular import
- from . import config_validation as cv # noqa: PLC0415
+ from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
@@ -1432,7 +1259,7 @@ def device_name(hass: HomeAssistant, lookup_value: str) -> str | None:
def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any:
"""Get the device specific attribute."""
- device_reg = device_registry.async_get(hass)
+ device_reg = dr.async_get(hass)
if not isinstance(device_or_entity_id, str):
raise TemplateError("Must provide a device or entity ID")
device = None
@@ -1475,14 +1302,14 @@ def is_device_attr(
def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues."""
- current_issues = issue_registry.async_get(hass).issues
+ current_issues = ir.async_get(hass).issues
# Use JSON for safe representation
return {k: v.to_json() for (k, v) in current_issues.items()}
def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None:
"""Get issue by domain and issue_id."""
- result = issue_registry.async_get(hass).async_get_issue(domain, issue_id)
+ result = ir.async_get(hass).async_get_issue(domain, issue_id)
if result:
return result.to_json()
return None
@@ -1505,7 +1332,7 @@ def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
return floors_list[0].floor_id
if aid := area_id(hass, lookup_value):
- area_reg = area_registry.async_get(hass)
+ area_reg = ar.async_get(hass)
if area := area_reg.async_get_area(aid):
return area.floor_id
@@ -1519,7 +1346,7 @@ def floor_name(hass: HomeAssistant, lookup_value: str) -> str | None:
return floor.name
if aid := area_id(hass, lookup_value):
- area_reg = area_registry.async_get(hass)
+ area_reg = ar.async_get(hass)
if (
(area := area_reg.async_get_area(aid))
and area.floor_id
@@ -1542,8 +1369,8 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
if _floor_id is None:
return []
- area_reg = area_registry.async_get(hass)
- entries = area_registry.async_entries_for_floor(area_reg, _floor_id)
+ area_reg = ar.async_get(hass)
+ entries = ar.async_entries_for_floor(area_reg, _floor_id)
return [entry.id for entry in entries if entry.id]
@@ -1558,12 +1385,12 @@ def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
def areas(hass: HomeAssistant) -> Iterable[str | None]:
"""Return all areas."""
- return list(area_registry.async_get(hass).areas)
+ return list(ar.async_get(hass).areas)
def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the area ID from an area name, alias, device id, or entity id."""
- area_reg = area_registry.async_get(hass)
+ area_reg = ar.async_get(hass)
lookup_str = str(lookup_value)
if area := area_reg.async_get_area_by_name(lookup_str):
return area.id
@@ -1571,10 +1398,10 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
if areas_list:
return areas_list[0].id
- ent_reg = entity_registry.async_get(hass)
- dev_reg = device_registry.async_get(hass)
+ ent_reg = er.async_get(hass)
+ dev_reg = dr.async_get(hass)
# Import here, not at top-level to avoid circular import
- from . import config_validation as cv # noqa: PLC0415
+ from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
@@ -1596,7 +1423,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
return None
-def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> str:
+def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str:
"""Get area name from valid area ID."""
area = area_reg.async_get_area(valid_area_id)
assert area
@@ -1605,14 +1432,14 @@ def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) ->
def area_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the area name from an area id, device id, or entity id."""
- area_reg = area_registry.async_get(hass)
+ area_reg = ar.async_get(hass)
if area := area_reg.async_get_area(lookup_value):
return area.name
- dev_reg = device_registry.async_get(hass)
- ent_reg = entity_registry.async_get(hass)
+ dev_reg = dr.async_get(hass)
+ ent_reg = er.async_get(hass)
# Import here, not at top-level to avoid circular import
- from . import config_validation as cv # noqa: PLC0415
+ from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
@@ -1649,19 +1476,18 @@ def area_entities(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]:
_area_id = area_id_or_name
if _area_id is None:
return []
- ent_reg = entity_registry.async_get(hass)
+ ent_reg = er.async_get(hass)
entity_ids = [
- entry.entity_id
- for entry in entity_registry.async_entries_for_area(ent_reg, _area_id)
+ entry.entity_id for entry in er.async_entries_for_area(ent_reg, _area_id)
]
- dev_reg = device_registry.async_get(hass)
+ dev_reg = dr.async_get(hass)
# We also need to add entities tied to a device in the area that don't themselves
# have an area specified since they inherit the area from the device.
entity_ids.extend(
[
entity.entity_id
- for device in device_registry.async_entries_for_area(dev_reg, _area_id)
- for entity in entity_registry.async_entries_for_device(ent_reg, device.id)
+ for device in dr.async_entries_for_area(dev_reg, _area_id)
+ for entity in er.async_entries_for_device(ent_reg, device.id)
if entity.area_id is None
]
)
@@ -1679,21 +1505,21 @@ def area_devices(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]:
_area_id = area_id(hass, area_id_or_name)
if _area_id is None:
return []
- dev_reg = device_registry.async_get(hass)
- entries = device_registry.async_entries_for_area(dev_reg, _area_id)
+ dev_reg = dr.async_get(hass)
+ entries = dr.async_entries_for_area(dev_reg, _area_id)
return [entry.id for entry in entries]
def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None]:
"""Return all labels, or those from a area ID, device ID, or entity ID."""
- label_reg = label_registry.async_get(hass)
+ label_reg = lr.async_get(hass)
if lookup_value is None:
return list(label_reg.labels)
- ent_reg = entity_registry.async_get(hass)
+ ent_reg = er.async_get(hass)
# Import here, not at top-level to avoid circular import
- from . import config_validation as cv # noqa: PLC0415
+ from homeassistant.helpers import config_validation as cv # noqa: PLC0415
lookup_value = str(lookup_value)
@@ -1706,12 +1532,12 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None
return list(entity.labels)
# Check if this could be a device ID
- dev_reg = device_registry.async_get(hass)
+ dev_reg = dr.async_get(hass)
if device := dev_reg.async_get(lookup_value):
return list(device.labels)
# Check if this could be a area ID
- area_reg = area_registry.async_get(hass)
+ area_reg = ar.async_get(hass)
if area := area_reg.async_get_area(lookup_value):
return list(area.labels)
@@ -1720,7 +1546,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None
def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
"""Get the label ID from a label name."""
- label_reg = label_registry.async_get(hass)
+ label_reg = lr.async_get(hass)
if label := label_reg.async_get_label_by_name(str(lookup_value)):
return label.label_id
return None
@@ -1728,7 +1554,7 @@ def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
def label_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the label name from a label ID."""
- label_reg = label_registry.async_get(hass)
+ label_reg = lr.async_get(hass)
if label := label_reg.async_get_label(lookup_value):
return label.name
return None
@@ -1736,7 +1562,7 @@ def label_name(hass: HomeAssistant, lookup_value: str) -> str | None:
def label_description(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the label description from a label ID."""
- label_reg = label_registry.async_get(hass)
+ label_reg = lr.async_get(hass)
if label := label_reg.async_get_label(lookup_value):
return label.description
return None
@@ -1755,8 +1581,8 @@ def label_areas(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]:
"""Return areas for a given label ID or name."""
if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None:
return []
- area_reg = area_registry.async_get(hass)
- entries = area_registry.async_entries_for_label(area_reg, _label_id)
+ area_reg = ar.async_get(hass)
+ entries = ar.async_entries_for_label(area_reg, _label_id)
return [entry.id for entry in entries]
@@ -1764,8 +1590,8 @@ def label_devices(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]:
"""Return device IDs for a given label ID or name."""
if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None:
return []
- dev_reg = device_registry.async_get(hass)
- entries = device_registry.async_entries_for_label(dev_reg, _label_id)
+ dev_reg = dr.async_get(hass)
+ entries = dr.async_entries_for_label(dev_reg, _label_id)
return [entry.id for entry in entries]
@@ -1773,8 +1599,8 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]:
"""Return entities for a given label ID or name."""
if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None:
return []
- ent_reg = entity_registry.async_get(hass)
- entries = entity_registry.async_entries_for_label(ent_reg, _label_id)
+ ent_reg = er.async_get(hass)
+ entries = er.async_entries_for_label(ent_reg, _label_id)
return [entry.entity_id for entry in entries]
@@ -1913,7 +1739,7 @@ def distance(hass: HomeAssistant, *args: Any) -> float | None:
def is_hidden_entity(hass: HomeAssistant, entity_id: str) -> bool:
"""Test if an entity is hidden."""
- entity_reg = entity_registry.async_get(hass)
+ entity_reg = er.async_get(hass)
entry = entity_reg.async_get(entity_id)
return entry is not None and entry.hidden
@@ -1955,7 +1781,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool:
def now(hass: HomeAssistant) -> datetime:
"""Record fetching now."""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
return dt_util.now()
@@ -1963,21 +1789,12 @@ def now(hass: HomeAssistant) -> datetime:
def utcnow(hass: HomeAssistant) -> datetime:
"""Record fetching utcnow."""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
return dt_util.utcnow()
-def raise_no_default(function: str, value: Any) -> NoReturn:
- """Log warning if no default is specified."""
- template, action = template_cv.get() or ("", "rendering or compiling")
- raise ValueError(
- f"Template error: {function} got invalid input '{value}' when {action} template"
- f" '{template}' but no default was specified"
- )
-
-
def forgiving_round(value, precision=0, method="common", default=_SENTINEL):
"""Filter to round a value."""
try:
@@ -2050,121 +1867,11 @@ def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]:
return wrapper
-def logarithm(value, base=math.e, default=_SENTINEL):
- """Filter and function to get logarithm of the value with a specific base."""
- try:
- base_float = float(base)
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("log", base)
- return default
- try:
- value_float = float(value)
- return math.log(value_float, base_float)
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("log", value)
- return default
-
-
-def sine(value, default=_SENTINEL):
- """Filter and function to get sine of the value."""
- try:
- return math.sin(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("sin", value)
- return default
-
-
-def cosine(value, default=_SENTINEL):
- """Filter and function to get cosine of the value."""
- try:
- return math.cos(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("cos", value)
- return default
-
-
-def tangent(value, default=_SENTINEL):
- """Filter and function to get tangent of the value."""
- try:
- return math.tan(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("tan", value)
- return default
-
-
-def arc_sine(value, default=_SENTINEL):
- """Filter and function to get arc sine of the value."""
- try:
- return math.asin(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("asin", value)
- return default
-
-
-def arc_cosine(value, default=_SENTINEL):
- """Filter and function to get arc cosine of the value."""
- try:
- return math.acos(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("acos", value)
- return default
-
-
-def arc_tangent(value, default=_SENTINEL):
- """Filter and function to get arc tangent of the value."""
- try:
- return math.atan(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("atan", value)
- return default
-
-
-def arc_tangent2(*args, default=_SENTINEL):
- """Filter and function to calculate four quadrant arc tangent of y / x.
-
- The parameters to atan2 may be passed either in an iterable or as separate arguments
- The default value may be passed either as a positional or in a keyword argument
- """
- try:
- if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)):
- if len(args) == 2 and default is _SENTINEL:
- # Default value passed as a positional argument
- default = args[1]
- args = args[0]
- elif len(args) == 3 and default is _SENTINEL:
- # Default value passed as a positional argument
- default = args[2]
-
- return math.atan2(float(args[0]), float(args[1]))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("atan2", args)
- return default
-
-
def version(value):
"""Filter and function to get version object of the value."""
return AwesomeVersion(value)
-def square_root(value, default=_SENTINEL):
- """Filter and function to get square root of the value."""
- try:
- return math.sqrt(float(value))
- except (ValueError, TypeError):
- if default is _SENTINEL:
- raise_no_default("sqrt", value)
- return default
-
-
def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL):
"""Filter to convert given timestamp to format."""
try:
@@ -2318,118 +2025,6 @@ def fail_when_undefined(value):
return value
-def min_max_from_filter(builtin_filter: Any, name: str) -> Any:
- """Convert a built-in min/max Jinja filter to a global function.
-
- The parameters may be passed as an iterable or as separate arguments.
- """
-
- @pass_environment
- @wraps(builtin_filter)
- def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any:
- if len(args) == 0:
- raise TypeError(f"{name} expected at least 1 argument, got 0")
-
- if len(args) == 1:
- if isinstance(args[0], Iterable):
- return builtin_filter(environment, args[0], **kwargs)
-
- raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
-
- return builtin_filter(environment, args, **kwargs)
-
- return pass_environment(wrapper)
-
-
-def average(*args: Any, default: Any = _SENTINEL) -> Any:
- """Filter and function to calculate the arithmetic mean.
-
- Calculates of an iterable or of two or more arguments.
-
- The parameters may be passed as an iterable or as separate arguments.
- """
- if len(args) == 0:
- raise TypeError("average expected at least 1 argument, got 0")
-
- # If first argument is iterable and more than 1 argument provided but not a named
- # default, then use 2nd argument as default.
- if isinstance(args[0], Iterable):
- average_list = args[0]
- if len(args) > 1 and default is _SENTINEL:
- default = args[1]
- elif len(args) == 1:
- raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
- else:
- average_list = args
-
- try:
- return statistics.fmean(average_list)
- except (TypeError, statistics.StatisticsError):
- if default is _SENTINEL:
- raise_no_default("average", args)
- return default
-
-
-def median(*args: Any, default: Any = _SENTINEL) -> Any:
- """Filter and function to calculate the median.
-
- Calculates median of an iterable of two or more arguments.
-
- The parameters may be passed as an iterable or as separate arguments.
- """
- if len(args) == 0:
- raise TypeError("median expected at least 1 argument, got 0")
-
- # If first argument is a list or tuple and more than 1 argument provided but not a named
- # default, then use 2nd argument as default.
- if isinstance(args[0], Iterable):
- median_list = args[0]
- if len(args) > 1 and default is _SENTINEL:
- default = args[1]
- elif len(args) == 1:
- raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
- else:
- median_list = args
-
- try:
- return statistics.median(median_list)
- except (TypeError, statistics.StatisticsError):
- if default is _SENTINEL:
- raise_no_default("median", args)
- return default
-
-
-def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any:
- """Filter and function to calculate the statistical mode.
-
- Calculates mode of an iterable of two or more arguments.
-
- The parameters may be passed as an iterable or as separate arguments.
- """
- if not args:
- raise TypeError("statistical_mode expected at least 1 argument, got 0")
-
- # If first argument is a list or tuple and more than 1 argument provided but not a named
- # default, then use 2nd argument as default.
- if len(args) == 1 and isinstance(args[0], Iterable):
- mode_list = args[0]
- elif isinstance(args[0], list | tuple):
- mode_list = args[0]
- if len(args) > 1 and default is _SENTINEL:
- default = args[1]
- elif len(args) == 1:
- raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
- else:
- mode_list = args
-
- try:
- return statistics.mode(mode_list)
- except (TypeError, statistics.StatisticsError):
- if default is _SENTINEL:
- raise_no_default("statistical_mode", args)
- return default
-
-
def forgiving_float(value, default=_SENTINEL):
"""Try to convert value to a float."""
try:
@@ -2477,31 +2072,6 @@ def is_number(value):
return True
-def _is_list(value: Any) -> bool:
- """Return whether a value is a list."""
- return isinstance(value, list)
-
-
-def _is_set(value: Any) -> bool:
- """Return whether a value is a set."""
- return isinstance(value, set)
-
-
-def _is_tuple(value: Any) -> bool:
- """Return whether a value is a tuple."""
- return isinstance(value, tuple)
-
-
-def _to_set(value: Any) -> set[Any]:
- """Convert value to set."""
- return set(value)
-
-
-def _to_tuple(value):
- """Convert value to tuple."""
- return tuple(value)
-
-
def _is_datetime(value: Any) -> bool:
"""Return whether a value is a datetime."""
return isinstance(value, datetime)
@@ -2512,61 +2082,6 @@ def _is_string_like(value: Any) -> bool:
return isinstance(value, (str, bytes, bytearray))
-def regex_match(value, find="", ignorecase=False):
- """Match value using regex."""
- if not isinstance(value, str):
- value = str(value)
- flags = re.IGNORECASE if ignorecase else 0
- return bool(_regex_cache(find, flags).match(value))
-
-
-_regex_cache = lru_cache(maxsize=128)(re.compile)
-
-
-def regex_replace(value="", find="", replace="", ignorecase=False):
- """Replace using regex."""
- if not isinstance(value, str):
- value = str(value)
- flags = re.IGNORECASE if ignorecase else 0
- return _regex_cache(find, flags).sub(replace, value)
-
-
-def regex_search(value, find="", ignorecase=False):
- """Search using regex."""
- if not isinstance(value, str):
- value = str(value)
- flags = re.IGNORECASE if ignorecase else 0
- return bool(_regex_cache(find, flags).search(value))
-
-
-def regex_findall_index(value, find="", index=0, ignorecase=False):
- """Find all matches using regex and then pick specific match index."""
- return regex_findall(value, find, ignorecase)[index]
-
-
-def regex_findall(value, find="", ignorecase=False):
- """Find all matches using regex."""
- if not isinstance(value, str):
- value = str(value)
- flags = re.IGNORECASE if ignorecase else 0
- return _regex_cache(find, flags).findall(value)
-
-
-def bitwise_and(first_value, second_value):
- """Perform a bitwise and operation."""
- return first_value & second_value
-
-
-def bitwise_or(first_value, second_value):
- """Perform a bitwise or operation."""
- return first_value | second_value
-
-
-def bitwise_xor(first_value, second_value):
- """Perform a bitwise xor operation."""
- return first_value ^ second_value
-
-
def struct_pack(value: Any | None, format_string: str) -> bytes | None:
"""Pack an object into a bytes object."""
try:
@@ -2608,32 +2123,6 @@ def from_hex(value: str) -> bytes:
return bytes.fromhex(value)
-def base64_encode(value: str | bytes) -> str:
- """Perform base64 encode."""
- if isinstance(value, str):
- value = value.encode("utf-8")
- return base64.b64encode(value).decode("utf-8")
-
-
-def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes:
- """Perform base64 decode."""
- decoded = base64.b64decode(value)
- if encoding:
- return decoded.decode(encoding)
-
- return decoded
-
-
-def ordinal(value):
- """Perform ordinal conversion."""
- suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd
- return str(value) + (
- suffixes[(int(str(value)[-1])) % 10]
- if int(str(value)[-2:]) % 100 not in range(11, 14)
- else "th"
- )
-
-
def from_json(value, default=_SENTINEL):
"""Convert a JSON string to an object."""
try:
@@ -2694,7 +2183,7 @@ def random_every_time(context, values):
def today_at(hass: HomeAssistant, time_str: str = "") -> datetime:
"""Record fetching now where the time has been replaced with value."""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
today = dt_util.start_of_local_day()
@@ -2724,7 +2213,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any:
supported so as not to break old templates.
"""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
if not isinstance(value, datetime):
@@ -2745,7 +2234,7 @@ def time_since(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -
If the value not a datetime object the input will be returned unmodified.
"""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
if not isinstance(value, datetime):
@@ -2767,7 +2256,7 @@ def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -
If the value not a datetime object the input will be returned unmodified.
"""
- if (render_info := _render_info.get()) is not None:
+ if (render_info := render_info_cv.get()) is not None:
render_info.has_time = True
if not isinstance(value, datetime):
@@ -2780,16 +2269,6 @@ def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -
return dt_util.get_time_remaining(value, precision)
-def urlencode(value):
- """Urlencode dictionary and return as UTF-8 string."""
- return urllib_urlencode(value).encode("utf-8")
-
-
-def slugify(value, separator="_"):
- """Convert a string into a slug, such as what is used for entity ids."""
- return slugify_util(value, separator=separator)
-
-
def iif(
value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL
) -> Any:
@@ -2810,98 +2289,11 @@ def iif(
return if_false
-def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]:
- """Shuffle a list, either with a seed or without."""
- if not args:
- raise TypeError("shuffle expected at least 1 argument, got 0")
-
- # If first argument is iterable and more than 1 argument provided
- # but not a named seed, then use 2nd argument as seed.
- if isinstance(args[0], Iterable):
- items = list(args[0])
- if len(args) > 1 and seed is None:
- seed = args[1]
- elif len(args) == 1:
- raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
- else:
- items = list(args)
-
- if seed:
- r = random.Random(seed)
- r.shuffle(items)
- else:
- random.shuffle(items)
- return items
-
-
def typeof(value: Any) -> Any:
"""Return the type of value passed to debug types."""
return value.__class__.__name__
-def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]:
- """Flattens list of lists."""
- if not isinstance(value, Iterable) or isinstance(value, str):
- raise TypeError(f"flatten expected a list, got {type(value).__name__}")
-
- flattened: list[Any] = []
- for item in value:
- if isinstance(item, Iterable) and not isinstance(item, str):
- if levels is None:
- flattened.extend(flatten(item))
- elif levels >= 1:
- flattened.extend(flatten(item, levels=(levels - 1)))
- else:
- flattened.append(item)
- else:
- flattened.append(item)
- return flattened
-
-
-def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
- """Return the common elements between two lists."""
- if not isinstance(value, Iterable) or isinstance(value, str):
- raise TypeError(f"intersect expected a list, got {type(value).__name__}")
- if not isinstance(other, Iterable) or isinstance(other, str):
- raise TypeError(f"intersect expected a list, got {type(other).__name__}")
-
- return list(set(value) & set(other))
-
-
-def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
- """Return elements in first list that are not in second list."""
- if not isinstance(value, Iterable) or isinstance(value, str):
- raise TypeError(f"difference expected a list, got {type(value).__name__}")
- if not isinstance(other, Iterable) or isinstance(other, str):
- raise TypeError(f"difference expected a list, got {type(other).__name__}")
-
- return list(set(value) - set(other))
-
-
-def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
- """Return all unique elements from both lists combined."""
- if not isinstance(value, Iterable) or isinstance(value, str):
- raise TypeError(f"union expected a list, got {type(value).__name__}")
- if not isinstance(other, Iterable) or isinstance(other, str):
- raise TypeError(f"union expected a list, got {type(other).__name__}")
-
- return list(set(value) | set(other))
-
-
-def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
- """Return elements that are in either list but not in both."""
- if not isinstance(value, Iterable) or isinstance(value, str):
- raise TypeError(
- f"symmetric_difference expected a list, got {type(value).__name__}"
- )
- if not isinstance(other, Iterable) or isinstance(other, str):
- raise TypeError(
- f"symmetric_difference expected a list, got {type(other).__name__}"
- )
-
- return list(set(value) ^ set(other))
-
-
def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]:
"""Combine multiple dictionaries into one."""
if not args:
@@ -2928,55 +2320,6 @@ def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]:
return result
-def md5(value: str) -> str:
- """Generate md5 hash from a string."""
- return hashlib.md5(value.encode()).hexdigest()
-
-
-def sha1(value: str) -> str:
- """Generate sha1 hash from a string."""
- return hashlib.sha1(value.encode()).hexdigest()
-
-
-def sha256(value: str) -> str:
- """Generate sha256 hash from a string."""
- return hashlib.sha256(value.encode()).hexdigest()
-
-
-def sha512(value: str) -> str:
- """Generate sha512 hash from a string."""
- return hashlib.sha512(value.encode()).hexdigest()
-
-
-class TemplateContextManager(AbstractContextManager):
- """Context manager to store template being parsed or rendered in a ContextVar."""
-
- def set_template(self, template_str: str, action: str) -> None:
- """Store template being parsed or rendered in a Contextvar to aid error handling."""
- template_cv.set((template_str, action))
-
- def __exit__(
- self,
- exc_type: type[BaseException] | None,
- exc_value: BaseException | None,
- traceback: TracebackType | None,
- ) -> None:
- """Raise any exception triggered within the runtime context."""
- template_cv.set(None)
-
-
-_template_context_manager = TemplateContextManager()
-
-
-def _render_with_context(
- template_str: str, template: jinja2.Template, **kwargs: Any
-) -> str:
- """Store template being rendered in a ContextVar to aid error handling."""
- with _template_context_manager as cm:
- cm.set_template(template_str, "rendering")
- return template.render(**kwargs)
-
-
def make_logging_undefined(
strict: bool | None, log_fn: Callable[[int, str], None] | None
) -> type[jinja2.Undefined]:
@@ -3096,64 +2439,42 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"""Initialise template environment."""
super().__init__(undefined=make_logging_undefined(strict, log_fn))
self.hass = hass
+ self.limited = limited
self.template_cache: weakref.WeakValueDictionary[
str | jinja2.nodes.Template, CodeType | None
] = weakref.WeakValueDictionary()
self.add_extension("jinja2.ext.loopcontrols")
self.add_extension("jinja2.ext.do")
+ self.add_extension("homeassistant.helpers.template.extensions.Base64Extension")
+ self.add_extension(
+ "homeassistant.helpers.template.extensions.CollectionExtension"
+ )
+ self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension")
+ self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
+ self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
+ self.add_extension("homeassistant.helpers.template.extensions.StringExtension")
- self.globals["acos"] = arc_cosine
+ self.globals["apply"] = apply
self.globals["as_datetime"] = as_datetime
self.globals["as_function"] = as_function
self.globals["as_local"] = dt_util.as_local
self.globals["as_timedelta"] = as_timedelta
self.globals["as_timestamp"] = forgiving_as_timestamp
- self.globals["asin"] = arc_sine
- self.globals["atan"] = arc_tangent
- self.globals["atan2"] = arc_tangent2
- self.globals["average"] = average
self.globals["bool"] = forgiving_boolean
self.globals["combine"] = combine
- self.globals["cos"] = cosine
- self.globals["difference"] = difference
- self.globals["e"] = math.e
- self.globals["flatten"] = flatten
self.globals["float"] = forgiving_float
self.globals["iif"] = iif
self.globals["int"] = forgiving_int
- self.globals["intersect"] = intersect
self.globals["is_number"] = is_number
- self.globals["log"] = logarithm
- self.globals["max"] = min_max_from_filter(self.filters["max"], "max")
- self.globals["md5"] = md5
- self.globals["median"] = median
self.globals["merge_response"] = merge_response
- self.globals["min"] = min_max_from_filter(self.filters["min"], "min")
self.globals["pack"] = struct_pack
- self.globals["pi"] = math.pi
- self.globals["set"] = _to_set
- self.globals["sha1"] = sha1
- self.globals["sha256"] = sha256
- self.globals["sha512"] = sha512
- self.globals["shuffle"] = shuffle
- self.globals["sin"] = sine
- self.globals["slugify"] = slugify
- self.globals["sqrt"] = square_root
- self.globals["statistical_mode"] = statistical_mode
self.globals["strptime"] = strptime
- self.globals["symmetric_difference"] = symmetric_difference
- self.globals["tan"] = tangent
- self.globals["tau"] = math.pi * 2
self.globals["timedelta"] = timedelta
- self.globals["tuple"] = _to_tuple
self.globals["typeof"] = typeof
- self.globals["union"] = union
self.globals["unpack"] = struct_unpack
- self.globals["urlencode"] = urlencode
self.globals["version"] = version
self.globals["zip"] = zip
- self.filters["acos"] = arc_cosine
self.filters["add"] = add
self.filters["apply"] = apply
self.filters["as_datetime"] = as_datetime
@@ -3161,59 +2482,26 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["as_local"] = dt_util.as_local
self.filters["as_timedelta"] = as_timedelta
self.filters["as_timestamp"] = forgiving_as_timestamp
- self.filters["asin"] = arc_sine
- self.filters["atan"] = arc_tangent
- self.filters["atan2"] = arc_tangent2
- self.filters["average"] = average
- self.filters["base64_decode"] = base64_decode
- self.filters["base64_encode"] = base64_encode
- self.filters["bitwise_and"] = bitwise_and
- self.filters["bitwise_or"] = bitwise_or
- self.filters["bitwise_xor"] = bitwise_xor
self.filters["bool"] = forgiving_boolean
self.filters["combine"] = combine
self.filters["contains"] = contains
- self.filters["cos"] = cosine
- self.filters["difference"] = difference
- self.filters["flatten"] = flatten
self.filters["float"] = forgiving_float_filter
self.filters["from_json"] = from_json
self.filters["from_hex"] = from_hex
self.filters["iif"] = iif
self.filters["int"] = forgiving_int_filter
- self.filters["intersect"] = intersect
self.filters["is_defined"] = fail_when_undefined
self.filters["is_number"] = is_number
- self.filters["log"] = logarithm
- self.filters["md5"] = md5
- self.filters["median"] = median
self.filters["multiply"] = multiply
self.filters["ord"] = ord
- self.filters["ordinal"] = ordinal
self.filters["pack"] = struct_pack
self.filters["random"] = random_every_time
- self.filters["regex_findall_index"] = regex_findall_index
- self.filters["regex_findall"] = regex_findall
- self.filters["regex_match"] = regex_match
- self.filters["regex_replace"] = regex_replace
- self.filters["regex_search"] = regex_search
self.filters["round"] = forgiving_round
- self.filters["sha1"] = sha1
- self.filters["sha256"] = sha256
- self.filters["sha512"] = sha512
- self.filters["shuffle"] = shuffle
- self.filters["sin"] = sine
- self.filters["slugify"] = slugify
- self.filters["sqrt"] = square_root
- self.filters["statistical_mode"] = statistical_mode
- self.filters["symmetric_difference"] = symmetric_difference
- self.filters["tan"] = tangent
self.filters["timestamp_custom"] = timestamp_custom
self.filters["timestamp_local"] = timestamp_local
self.filters["timestamp_utc"] = timestamp_utc
self.filters["to_json"] = to_json
self.filters["typeof"] = typeof
- self.filters["union"] = union
self.filters["unpack"] = struct_unpack
self.filters["version"] = version
@@ -3221,12 +2509,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.tests["contains"] = contains
self.tests["datetime"] = _is_datetime
self.tests["is_number"] = is_number
- self.tests["list"] = _is_list
- self.tests["match"] = regex_match
- self.tests["search"] = regex_search
- self.tests["set"] = _is_set
self.tests["string_like"] = _is_string_like
- self.tests["tuple"] = _is_tuple
if hass is None:
return
diff --git a/homeassistant/helpers/template/context.py b/homeassistant/helpers/template/context.py
new file mode 100644
index 00000000000..3f2a56fba48
--- /dev/null
+++ b/homeassistant/helpers/template/context.py
@@ -0,0 +1,45 @@
+"""Template context management for Home Assistant."""
+
+from __future__ import annotations
+
+from contextlib import AbstractContextManager
+from contextvars import ContextVar
+from types import TracebackType
+from typing import Any
+
+import jinja2
+
+# Context variable for template string tracking
+template_cv: ContextVar[tuple[str, str] | None] = ContextVar(
+ "template_cv", default=None
+)
+
+
+class TemplateContextManager(AbstractContextManager):
+ """Context manager to store template being parsed or rendered in a ContextVar."""
+
+ def set_template(self, template_str: str, action: str) -> None:
+ """Store template being parsed or rendered in a Contextvar to aid error handling."""
+ template_cv.set((template_str, action))
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> None:
+ """Raise any exception triggered within the runtime context."""
+ template_cv.set(None)
+
+
+# Global context manager instance
+template_context_manager = TemplateContextManager()
+
+
+def render_with_context(
+ template_str: str, template: jinja2.Template, **kwargs: Any
+) -> str:
+ """Store template being rendered in a ContextVar to aid error handling."""
+ with template_context_manager as cm:
+ cm.set_template(template_str, "rendering")
+ return template.render(**kwargs)
diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py
new file mode 100644
index 00000000000..80a4c1d46f6
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/__init__.py
@@ -0,0 +1,17 @@
+"""Home Assistant template extensions."""
+
+from .base64 import Base64Extension
+from .collection import CollectionExtension
+from .crypto import CryptoExtension
+from .math import MathExtension
+from .regex import RegexExtension
+from .string import StringExtension
+
+__all__ = [
+ "Base64Extension",
+ "CollectionExtension",
+ "CryptoExtension",
+ "MathExtension",
+ "RegexExtension",
+ "StringExtension",
+]
diff --git a/homeassistant/helpers/template/extensions/base.py b/homeassistant/helpers/template/extensions/base.py
new file mode 100644
index 00000000000..87a3625bdbb
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/base.py
@@ -0,0 +1,60 @@
+"""Base extension class for Home Assistant template extensions."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from jinja2.ext import Extension
+from jinja2.nodes import Node
+from jinja2.parser import Parser
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+
+@dataclass
+class TemplateFunction:
+ """Definition for a template function, filter, or test."""
+
+ name: str
+ func: Callable[..., Any] | Any
+ as_global: bool = False
+ as_filter: bool = False
+ as_test: bool = False
+ limited_ok: bool = (
+ True # Whether this function is available in limited environments
+ )
+
+
+class BaseTemplateExtension(Extension):
+ """Base class for Home Assistant template extensions."""
+
+ environment: TemplateEnvironment
+
+ def __init__(
+ self,
+ environment: TemplateEnvironment,
+ *,
+ functions: list[TemplateFunction] | None = None,
+ ) -> None:
+ """Initialize the extension with a list of template functions."""
+ super().__init__(environment)
+
+ if functions:
+ for template_func in functions:
+ # Skip functions not allowed in limited environments
+ if self.environment.limited and not template_func.limited_ok:
+ continue
+
+ if template_func.as_global:
+ environment.globals[template_func.name] = template_func.func
+ if template_func.as_filter:
+ environment.filters[template_func.name] = template_func.func
+ if template_func.as_test:
+ environment.tests[template_func.name] = template_func.func
+
+ def parse(self, parser: Parser) -> Node | list[Node]:
+ """Required by Jinja2 Extension base class."""
+ return []
diff --git a/homeassistant/helpers/template/extensions/base64.py b/homeassistant/helpers/template/extensions/base64.py
new file mode 100644
index 00000000000..3ec88bf14f4
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/base64.py
@@ -0,0 +1,50 @@
+"""Base64 encoding and decoding functions for Home Assistant templates."""
+
+from __future__ import annotations
+
+import base64
+from typing import TYPE_CHECKING
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+
+class Base64Extension(BaseTemplateExtension):
+ """Jinja2 extension for base64 encoding and decoding functions."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the base64 extension."""
+ super().__init__(
+ environment,
+ functions=[
+ TemplateFunction(
+ "base64_encode",
+ self.base64_encode,
+ as_filter=True,
+ limited_ok=False,
+ ),
+ TemplateFunction(
+ "base64_decode",
+ self.base64_decode,
+ as_filter=True,
+ limited_ok=False,
+ ),
+ ],
+ )
+
+ @staticmethod
+ def base64_encode(value: str | bytes) -> str:
+ """Encode a string or bytes to base64."""
+ if isinstance(value, str):
+ value = value.encode("utf-8")
+ return base64.b64encode(value).decode("utf-8")
+
+ @staticmethod
+ def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes:
+ """Decode a base64 string."""
+ decoded = base64.b64decode(value)
+ if encoding is None:
+ return decoded
+ return decoded.decode(encoding)
diff --git a/homeassistant/helpers/template/extensions/collection.py b/homeassistant/helpers/template/extensions/collection.py
new file mode 100644
index 00000000000..b0f3313dc81
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/collection.py
@@ -0,0 +1,191 @@
+"""Collection and data structure functions for Home Assistant templates."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, MutableSequence
+import random
+from typing import TYPE_CHECKING, Any
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+
+class CollectionExtension(BaseTemplateExtension):
+ """Extension for collection and data structure operations."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the collection extension."""
+ super().__init__(
+ environment,
+ functions=[
+ TemplateFunction(
+ "flatten",
+ self.flatten,
+ as_global=True,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "shuffle",
+ self.shuffle,
+ as_global=True,
+ as_filter=True,
+ ),
+ # Set operations
+ TemplateFunction(
+ "intersect",
+ self.intersect,
+ as_global=True,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "difference",
+ self.difference,
+ as_global=True,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "union",
+ self.union,
+ as_global=True,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "symmetric_difference",
+ self.symmetric_difference,
+ as_global=True,
+ as_filter=True,
+ ),
+ # Type conversion functions
+ TemplateFunction(
+ "set",
+ self.to_set,
+ as_global=True,
+ ),
+ TemplateFunction(
+ "tuple",
+ self.to_tuple,
+ as_global=True,
+ ),
+ # Type checking functions (tests)
+ TemplateFunction(
+ "list",
+ self.is_list,
+ as_test=True,
+ ),
+ TemplateFunction(
+ "set",
+ self.is_set,
+ as_test=True,
+ ),
+ TemplateFunction(
+ "tuple",
+ self.is_tuple,
+ as_test=True,
+ ),
+ ],
+ )
+
+ def flatten(self, value: Iterable[Any], levels: int | None = None) -> list[Any]:
+ """Flatten list of lists."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"flatten expected a list, got {type(value).__name__}")
+
+ flattened: list[Any] = []
+ for item in value:
+ if isinstance(item, Iterable) and not isinstance(item, str):
+ if levels is None:
+ flattened.extend(self.flatten(item))
+ elif levels >= 1:
+ flattened.extend(self.flatten(item, levels=(levels - 1)))
+ else:
+ flattened.append(item)
+ else:
+ flattened.append(item)
+ return flattened
+
+ def shuffle(self, *args: Any, seed: Any = None) -> MutableSequence[Any]:
+ """Shuffle a list, either with a seed or without."""
+ if not args:
+ raise TypeError("shuffle expected at least 1 argument, got 0")
+
+ # If first argument is iterable and more than 1 argument provided
+ # but not a named seed, then use 2nd argument as seed.
+ if isinstance(args[0], Iterable) and not isinstance(args[0], str):
+ items = list(args[0])
+ if len(args) > 1 and seed is None:
+ seed = args[1]
+ elif len(args) == 1:
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+ else:
+ items = list(args)
+
+ if seed:
+ r = random.Random(seed)
+ r.shuffle(items)
+ else:
+ random.shuffle(items)
+ return items
+
+ def intersect(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return the common elements between two lists."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"intersect expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"intersect expected a list, got {type(other).__name__}")
+
+ return list(set(value) & set(other))
+
+ def difference(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return elements in first list that are not in second list."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"difference expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"difference expected a list, got {type(other).__name__}")
+
+ return list(set(value) - set(other))
+
+ def union(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return all unique elements from both lists combined."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"union expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"union expected a list, got {type(other).__name__}")
+
+ return list(set(value) | set(other))
+
+ def symmetric_difference(
+ self, value: Iterable[Any], other: Iterable[Any]
+ ) -> list[Any]:
+ """Return elements that are in either list but not in both."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(
+ f"symmetric_difference expected a list, got {type(value).__name__}"
+ )
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(
+ f"symmetric_difference expected a list, got {type(other).__name__}"
+ )
+
+ return list(set(value) ^ set(other))
+
+ def to_set(self, value: Any) -> set[Any]:
+ """Convert value to set."""
+ return set(value)
+
+ def to_tuple(self, value: Any) -> tuple[Any, ...]:
+ """Convert value to tuple."""
+ return tuple(value)
+
+ def is_list(self, value: Any) -> bool:
+ """Return whether a value is a list."""
+ return isinstance(value, list)
+
+ def is_set(self, value: Any) -> bool:
+ """Return whether a value is a set."""
+ return isinstance(value, set)
+
+ def is_tuple(self, value: Any) -> bool:
+ """Return whether a value is a tuple."""
+ return isinstance(value, tuple)
diff --git a/homeassistant/helpers/template/extensions/crypto.py b/homeassistant/helpers/template/extensions/crypto.py
new file mode 100644
index 00000000000..c3ff165d727
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/crypto.py
@@ -0,0 +1,64 @@
+"""Cryptographic hash functions for Home Assistant templates."""
+
+from __future__ import annotations
+
+import hashlib
+from typing import TYPE_CHECKING
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+
+class CryptoExtension(BaseTemplateExtension):
+ """Jinja2 extension for cryptographic hash functions."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the crypto extension."""
+ super().__init__(
+ environment,
+ functions=[
+ # Hash functions (as globals and filters)
+ TemplateFunction(
+ "md5", self.md5, as_global=True, as_filter=True, limited_ok=False
+ ),
+ TemplateFunction(
+ "sha1", self.sha1, as_global=True, as_filter=True, limited_ok=False
+ ),
+ TemplateFunction(
+ "sha256",
+ self.sha256,
+ as_global=True,
+ as_filter=True,
+ limited_ok=False,
+ ),
+ TemplateFunction(
+ "sha512",
+ self.sha512,
+ as_global=True,
+ as_filter=True,
+ limited_ok=False,
+ ),
+ ],
+ )
+
+ @staticmethod
+ def md5(value: str) -> str:
+ """Generate md5 hash from a string."""
+ return hashlib.md5(value.encode()).hexdigest()
+
+ @staticmethod
+ def sha1(value: str) -> str:
+ """Generate sha1 hash from a string."""
+ return hashlib.sha1(value.encode()).hexdigest()
+
+ @staticmethod
+ def sha256(value: str) -> str:
+ """Generate sha256 hash from a string."""
+ return hashlib.sha256(value.encode()).hexdigest()
+
+ @staticmethod
+ def sha512(value: str) -> str:
+ """Generate sha512 hash from a string."""
+ return hashlib.sha512(value.encode()).hexdigest()
diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py
new file mode 100644
index 00000000000..9ec7016399f
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/math.py
@@ -0,0 +1,329 @@
+"""Mathematical and statistical functions for Home Assistant templates."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from functools import wraps
+import math
+import statistics
+from typing import TYPE_CHECKING, Any
+
+import jinja2
+from jinja2 import pass_environment
+
+from homeassistant.helpers.template.helpers import raise_no_default
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+# Sentinel object for default parameter
+_SENTINEL = object()
+
+
+class MathExtension(BaseTemplateExtension):
+ """Jinja2 extension for mathematical and statistical functions."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the math extension."""
+ super().__init__(
+ environment,
+ functions=[
+ # Math constants (as globals only) - these are values, not functions
+ TemplateFunction("e", math.e, as_global=True),
+ TemplateFunction("pi", math.pi, as_global=True),
+ TemplateFunction("tau", math.pi * 2, as_global=True),
+ # Trigonometric functions (as globals and filters)
+ TemplateFunction("sin", self.sine, as_global=True, as_filter=True),
+ TemplateFunction("cos", self.cosine, as_global=True, as_filter=True),
+ TemplateFunction("tan", self.tangent, as_global=True, as_filter=True),
+ TemplateFunction("asin", self.arc_sine, as_global=True, as_filter=True),
+ TemplateFunction(
+ "acos", self.arc_cosine, as_global=True, as_filter=True
+ ),
+ TemplateFunction(
+ "atan", self.arc_tangent, as_global=True, as_filter=True
+ ),
+ TemplateFunction(
+ "atan2", self.arc_tangent2, as_global=True, as_filter=True
+ ),
+ # Advanced math functions (as globals and filters)
+ TemplateFunction("log", self.logarithm, as_global=True, as_filter=True),
+ TemplateFunction(
+ "sqrt", self.square_root, as_global=True, as_filter=True
+ ),
+ # Statistical functions (as globals and filters)
+ TemplateFunction(
+ "average", self.average, as_global=True, as_filter=True
+ ),
+ TemplateFunction("median", self.median, as_global=True, as_filter=True),
+ TemplateFunction(
+ "statistical_mode",
+ self.statistical_mode,
+ as_global=True,
+ as_filter=True,
+ ),
+ # Min/Max functions (as globals only)
+ TemplateFunction("min", self.min_max_min, as_global=True),
+ TemplateFunction("max", self.min_max_max, as_global=True),
+ # Bitwise operations (as globals and filters)
+ TemplateFunction(
+ "bitwise_and", self.bitwise_and, as_global=True, as_filter=True
+ ),
+ TemplateFunction(
+ "bitwise_or", self.bitwise_or, as_global=True, as_filter=True
+ ),
+ TemplateFunction(
+ "bitwise_xor", self.bitwise_xor, as_global=True, as_filter=True
+ ),
+ ],
+ )
+
+ @staticmethod
+ def logarithm(value: Any, base: Any = math.e, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get logarithm of the value with a specific base."""
+ try:
+ base_float = float(base)
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("log", base)
+ return default
+ try:
+ value_float = float(value)
+ return math.log(value_float, base_float)
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("log", value)
+ return default
+
+ @staticmethod
+ def sine(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get sine of the value."""
+ try:
+ return math.sin(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("sin", value)
+ return default
+
+ @staticmethod
+ def cosine(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get cosine of the value."""
+ try:
+ return math.cos(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("cos", value)
+ return default
+
+ @staticmethod
+ def tangent(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get tangent of the value."""
+ try:
+ return math.tan(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("tan", value)
+ return default
+
+ @staticmethod
+ def arc_sine(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get arc sine of the value."""
+ try:
+ return math.asin(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("asin", value)
+ return default
+
+ @staticmethod
+ def arc_cosine(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get arc cosine of the value."""
+ try:
+ return math.acos(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("acos", value)
+ return default
+
+ @staticmethod
+ def arc_tangent(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get arc tangent of the value."""
+ try:
+ return math.atan(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("atan", value)
+ return default
+
+ @staticmethod
+ def arc_tangent2(*args: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to calculate four quadrant arc tangent of y / x.
+
+ The parameters to atan2 may be passed either in an iterable or as separate arguments
+ The default value may be passed either as a positional or in a keyword argument
+ """
+ try:
+ if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)):
+ if len(args) == 2 and default is _SENTINEL:
+ # Default value passed as a positional argument
+ default = args[1]
+ args = tuple(args[0])
+ elif len(args) == 3 and default is _SENTINEL:
+ # Default value passed as a positional argument
+ default = args[2]
+
+ return math.atan2(float(args[0]), float(args[1]))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("atan2", args)
+ return default
+
+ @staticmethod
+ def square_root(value: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to get square root of the value."""
+ try:
+ return math.sqrt(float(value))
+ except (ValueError, TypeError):
+ if default is _SENTINEL:
+ raise_no_default("sqrt", value)
+ return default
+
+ @staticmethod
+ def average(*args: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to calculate the arithmetic mean.
+
+ Calculates of an iterable or of two or more arguments.
+
+ The parameters may be passed as an iterable or as separate arguments.
+ """
+ if len(args) == 0:
+ raise TypeError("average expected at least 1 argument, got 0")
+
+ # If first argument is iterable and more than 1 argument provided but not a named
+ # default, then use 2nd argument as default.
+ if isinstance(args[0], Iterable):
+ average_list = args[0]
+ if len(args) > 1 and default is _SENTINEL:
+ default = args[1]
+ elif len(args) == 1:
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+ else:
+ average_list = args
+
+ try:
+ return statistics.fmean(average_list)
+ except (TypeError, statistics.StatisticsError):
+ if default is _SENTINEL:
+ raise_no_default("average", args)
+ return default
+
+ @staticmethod
+ def median(*args: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to calculate the median.
+
+ Calculates median of an iterable of two or more arguments.
+
+ The parameters may be passed as an iterable or as separate arguments.
+ """
+ if len(args) == 0:
+ raise TypeError("median expected at least 1 argument, got 0")
+
+ # If first argument is a list or tuple and more than 1 argument provided but not a named
+ # default, then use 2nd argument as default.
+ if isinstance(args[0], Iterable):
+ median_list = args[0]
+ if len(args) > 1 and default is _SENTINEL:
+ default = args[1]
+ elif len(args) == 1:
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+ else:
+ median_list = args
+
+ try:
+ return statistics.median(median_list)
+ except (TypeError, statistics.StatisticsError):
+ if default is _SENTINEL:
+ raise_no_default("median", args)
+ return default
+
+ @staticmethod
+ def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any:
+ """Filter and function to calculate the statistical mode.
+
+ Calculates mode of an iterable of two or more arguments.
+
+ The parameters may be passed as an iterable or as separate arguments.
+ """
+ if not args:
+ raise TypeError("statistical_mode expected at least 1 argument, got 0")
+
+ # If first argument is a list or tuple and more than 1 argument provided but not a named
+ # default, then use 2nd argument as default.
+ if len(args) == 1 and isinstance(args[0], Iterable):
+ mode_list = args[0]
+ elif isinstance(args[0], list | tuple):
+ mode_list = args[0]
+ if len(args) > 1 and default is _SENTINEL:
+ default = args[1]
+ elif len(args) == 1:
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+ else:
+ mode_list = args
+
+ try:
+ return statistics.mode(mode_list)
+ except (TypeError, statistics.StatisticsError):
+ if default is _SENTINEL:
+ raise_no_default("statistical_mode", args)
+ return default
+
+ def min_max_from_filter(self, builtin_filter: Any, name: str) -> Any:
+ """Convert a built-in min/max Jinja filter to a global function.
+
+ The parameters may be passed as an iterable or as separate arguments.
+ """
+
+ @pass_environment
+ @wraps(builtin_filter)
+ def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any:
+ if len(args) == 0:
+ raise TypeError(f"{name} expected at least 1 argument, got 0")
+
+ if len(args) == 1:
+ if isinstance(args[0], Iterable):
+ return builtin_filter(environment, args[0], **kwargs)
+
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+
+ return builtin_filter(environment, args, **kwargs)
+
+ return pass_environment(wrapper)
+
+ def min_max_min(self, *args: Any, **kwargs: Any) -> Any:
+ """Min function using built-in filter."""
+ return self.min_max_from_filter(self.environment.filters["min"], "min")(
+ self.environment, *args, **kwargs
+ )
+
+ def min_max_max(self, *args: Any, **kwargs: Any) -> Any:
+ """Max function using built-in filter."""
+ return self.min_max_from_filter(self.environment.filters["max"], "max")(
+ self.environment, *args, **kwargs
+ )
+
+ @staticmethod
+ def bitwise_and(first_value: Any, second_value: Any) -> Any:
+ """Perform a bitwise and operation."""
+ return first_value & second_value
+
+ @staticmethod
+ def bitwise_or(first_value: Any, second_value: Any) -> Any:
+ """Perform a bitwise or operation."""
+ return first_value | second_value
+
+ @staticmethod
+ def bitwise_xor(first_value: Any, second_value: Any) -> Any:
+ """Perform a bitwise xor operation."""
+ return first_value ^ second_value
diff --git a/homeassistant/helpers/template/extensions/regex.py b/homeassistant/helpers/template/extensions/regex.py
new file mode 100644
index 00000000000..f9ec90bc2fa
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/regex.py
@@ -0,0 +1,109 @@
+"""Jinja2 extension for regular expression functions."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+import re
+from typing import TYPE_CHECKING, Any
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+# Module-level regex cache shared across all instances
+_regex_cache = lru_cache(maxsize=128)(re.compile)
+
+
+class RegexExtension(BaseTemplateExtension):
+ """Jinja2 extension for regular expression functions."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the regex extension."""
+
+ super().__init__(
+ environment,
+ functions=[
+ TemplateFunction(
+ "regex_match",
+ self.regex_match,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "regex_search",
+ self.regex_search,
+ as_filter=True,
+ ),
+ # Register tests with different names
+ TemplateFunction(
+ "match",
+ self.regex_match,
+ as_test=True,
+ ),
+ TemplateFunction(
+ "search",
+ self.regex_search,
+ as_test=True,
+ ),
+ TemplateFunction(
+ "regex_replace",
+ self.regex_replace,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "regex_findall",
+ self.regex_findall,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "regex_findall_index",
+ self.regex_findall_index,
+ as_filter=True,
+ ),
+ ],
+ )
+
+ def regex_match(self, value: Any, find: str = "", ignorecase: bool = False) -> bool:
+ """Match value using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.IGNORECASE if ignorecase else 0
+ return bool(_regex_cache(find, flags).match(value))
+
+ def regex_replace(
+ self,
+ value: Any = "",
+ find: str = "",
+ replace: str = "",
+ ignorecase: bool = False,
+ ) -> str:
+ """Replace using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.IGNORECASE if ignorecase else 0
+ result = _regex_cache(find, flags).sub(replace, value)
+ return str(result)
+
+ def regex_search(
+ self, value: Any, find: str = "", ignorecase: bool = False
+ ) -> bool:
+ """Search using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.IGNORECASE if ignorecase else 0
+ return bool(_regex_cache(find, flags).search(value))
+
+ def regex_findall_index(
+ self, value: Any, find: str = "", index: int = 0, ignorecase: bool = False
+ ) -> str:
+ """Find all matches using regex and then pick specific match index."""
+ return self.regex_findall(value, find, ignorecase)[index]
+
+ def regex_findall(
+ self, value: Any, find: str = "", ignorecase: bool = False
+ ) -> list[str]:
+ """Find all matches using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.IGNORECASE if ignorecase else 0
+ return _regex_cache(find, flags).findall(value)
diff --git a/homeassistant/helpers/template/extensions/string.py b/homeassistant/helpers/template/extensions/string.py
new file mode 100644
index 00000000000..ee0af35e2a8
--- /dev/null
+++ b/homeassistant/helpers/template/extensions/string.py
@@ -0,0 +1,58 @@
+"""Jinja2 extension for string processing functions."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlencode as urllib_urlencode
+
+from homeassistant.util import slugify as slugify_util
+
+from .base import BaseTemplateExtension, TemplateFunction
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.template import TemplateEnvironment
+
+
+class StringExtension(BaseTemplateExtension):
+ """Jinja2 extension for string processing functions."""
+
+ def __init__(self, environment: TemplateEnvironment) -> None:
+ """Initialize the string extension."""
+ super().__init__(
+ environment,
+ functions=[
+ TemplateFunction(
+ "ordinal",
+ self.ordinal,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "slugify",
+ self.slugify,
+ as_global=True,
+ as_filter=True,
+ ),
+ TemplateFunction(
+ "urlencode",
+ self.urlencode,
+ as_global=True,
+ ),
+ ],
+ )
+
+ def ordinal(self, value: Any) -> str:
+ """Perform ordinal conversion."""
+ suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd
+ return str(value) + (
+ suffixes[(int(str(value)[-1])) % 10]
+ if int(str(value)[-2:]) % 100 not in range(11, 14)
+ else "th"
+ )
+
+ def slugify(self, value: Any, separator: str = "_") -> str:
+ """Convert a string into a slug, such as what is used for entity ids."""
+ return slugify_util(str(value), separator=separator)
+
+ def urlencode(self, value: Any) -> bytes:
+ """Urlencode dictionary and return as UTF-8 string."""
+ return urllib_urlencode(value).encode("utf-8")
diff --git a/homeassistant/helpers/template/helpers.py b/homeassistant/helpers/template/helpers.py
new file mode 100644
index 00000000000..2e5942f3b74
--- /dev/null
+++ b/homeassistant/helpers/template/helpers.py
@@ -0,0 +1,16 @@
+"""Template helper functions for Home Assistant."""
+
+from __future__ import annotations
+
+from typing import Any, NoReturn
+
+from .context import template_cv
+
+
+def raise_no_default(function: str, value: Any) -> NoReturn:
+ """Raise ValueError when no default is specified for template functions."""
+ template, action = template_cv.get() or ("", "rendering or compiling")
+ raise ValueError(
+ f"Template error: {function} got invalid input '{value}' when {action} template"
+ f" '{template}' but no default was specified"
+ )
diff --git a/homeassistant/helpers/template/render_info.py b/homeassistant/helpers/template/render_info.py
new file mode 100644
index 00000000000..3899ab0add1
--- /dev/null
+++ b/homeassistant/helpers/template/render_info.py
@@ -0,0 +1,155 @@
+"""Template render information tracking for Home Assistant."""
+
+from __future__ import annotations
+
+import collections.abc
+from collections.abc import Callable
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, cast
+
+from homeassistant.core import split_entity_id
+
+if TYPE_CHECKING:
+ from homeassistant.exceptions import TemplateError
+
+ from . import Template
+
+# Rate limiting constants
+ALL_STATES_RATE_LIMIT = 60 # seconds
+DOMAIN_STATES_RATE_LIMIT = 1 # seconds
+
+# Context variable for render information tracking
+render_info_cv: ContextVar[RenderInfo | None] = ContextVar(
+ "render_info_cv", default=None
+)
+
+
+# Filter functions for efficiency
+def _true(entity_id: str) -> bool:
+ """Return True for all entity IDs."""
+ return True
+
+
+def _false(entity_id: str) -> bool:
+ """Return False for all entity IDs."""
+ return False
+
+
+class RenderInfo:
+ """Holds information about a template render."""
+
+ __slots__ = (
+ "_result",
+ "all_states",
+ "all_states_lifecycle",
+ "domains",
+ "domains_lifecycle",
+ "entities",
+ "exception",
+ "filter",
+ "filter_lifecycle",
+ "has_time",
+ "is_static",
+ "rate_limit",
+ "template",
+ )
+
+ def __init__(self, template: Template) -> None:
+ """Initialise."""
+ self.template = template
+ # Will be set sensibly once frozen.
+ self.filter_lifecycle: Callable[[str], bool] = _true
+ self.filter: Callable[[str], bool] = _true
+ self._result: str | None = None
+ self.is_static = False
+ self.exception: TemplateError | None = None
+ self.all_states = False
+ self.all_states_lifecycle = False
+ self.domains: collections.abc.Set[str] = set()
+ self.domains_lifecycle: collections.abc.Set[str] = set()
+ self.entities: collections.abc.Set[str] = set()
+ self.rate_limit: float | None = None
+ self.has_time = False
+
+ def __repr__(self) -> str:
+ """Representation of RenderInfo."""
+ return (
+ f""
+ )
+
+ def _filter_domains_and_entities(self, entity_id: str) -> bool:
+ """Template should re-render if the entity state changes.
+
+ Only when we match specific domains or entities.
+ """
+ return (
+ split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities
+ )
+
+ def _filter_entities(self, entity_id: str) -> bool:
+ """Template should re-render if the entity state changes.
+
+ Only when we match specific entities.
+ """
+ return entity_id in self.entities
+
+ def _filter_lifecycle_domains(self, entity_id: str) -> bool:
+ """Template should re-render if the entity is added or removed.
+
+ Only with domains watched.
+ """
+ return split_entity_id(entity_id)[0] in self.domains_lifecycle
+
+ def result(self) -> str:
+ """Results of the template computation."""
+ if self.exception is not None:
+ raise self.exception
+ return cast(str, self._result)
+
+ def _freeze_static(self) -> None:
+ self.is_static = True
+ self._freeze_sets()
+ self.all_states = False
+
+ def _freeze_sets(self) -> None:
+ self.entities = frozenset(self.entities)
+ self.domains = frozenset(self.domains)
+ self.domains_lifecycle = frozenset(self.domains_lifecycle)
+
+ def _freeze(self) -> None:
+ self._freeze_sets()
+
+ if self.rate_limit is None:
+ if self.all_states or self.exception:
+ self.rate_limit = ALL_STATES_RATE_LIMIT
+ elif self.domains or self.domains_lifecycle:
+ self.rate_limit = DOMAIN_STATES_RATE_LIMIT
+
+ if self.exception:
+ return
+
+ if not self.all_states_lifecycle:
+ if self.domains_lifecycle:
+ self.filter_lifecycle = self._filter_lifecycle_domains
+ else:
+ self.filter_lifecycle = _false
+
+ if self.all_states:
+ return
+
+ if self.domains:
+ self.filter = self._filter_domains_and_entities
+ elif self.entities:
+ self.filter = self._filter_entities
+ else:
+ self.filter = _false
diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py
index 741fac3fcf7..5c844c81cf4 100644
--- a/homeassistant/helpers/trigger.py
+++ b/homeassistant/helpers/trigger.py
@@ -18,8 +18,10 @@ from homeassistant.const import (
CONF_ALIAS,
CONF_ENABLED,
CONF_ID,
+ CONF_OPTIONS,
CONF_PLATFORM,
CONF_SELECTOR,
+ CONF_TARGET,
CONF_VARIABLES,
)
from homeassistant.core import (
@@ -74,17 +76,17 @@ TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers")
# Basic schemas to sanity check the trigger descriptions,
# full validation is done by hassfest.triggers
-_FIELD_SCHEMA = vol.Schema(
+_FIELD_DESCRIPTION_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SELECTOR): selector.validate_selector,
},
extra=vol.ALLOW_EXTRA,
)
-_TRIGGER_SCHEMA = vol.Schema(
+_TRIGGER_DESCRIPTION_SCHEMA = vol.Schema(
{
- vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None),
- vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}),
+ vol.Optional("target"): TargetSelector.CONFIG_SCHEMA,
+ vol.Optional("fields"): vol.Schema({str: _FIELD_DESCRIPTION_SCHEMA}),
},
extra=vol.ALLOW_EXTRA,
)
@@ -97,10 +99,10 @@ def starts_with_dot(key: str) -> str:
return key
-_TRIGGERS_SCHEMA = vol.Schema(
+_TRIGGERS_DESCRIPTION_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, starts_with_dot)): object,
- cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA),
+ cv.underscore_slug: vol.Any(None, _TRIGGER_DESCRIPTION_SCHEMA),
}
)
@@ -165,11 +167,41 @@ async def _register_trigger_platform(
_LOGGER.exception("Error while notifying trigger platform listener")
+_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
+ {
+ vol.Optional(CONF_OPTIONS): object,
+ vol.Optional(CONF_TARGET): cv.TARGET_FIELDS,
+ }
+)
+
+
class Trigger(abc.ABC):
"""Trigger class."""
- def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
- """Initialize trigger."""
+ @classmethod
+ async def async_validate_complete_config(
+ cls, hass: HomeAssistant, complete_config: ConfigType
+ ) -> ConfigType:
+ """Validate complete config.
+
+ The complete config includes fields that are generic to all triggers,
+ such as the alias or the ID.
+ This method should be overridden by triggers that need to migrate
+ from the old-style config.
+ """
+ complete_config = _TRIGGER_SCHEMA(complete_config)
+
+ specific_config: ConfigType = {}
+ for key in (CONF_OPTIONS, CONF_TARGET):
+ if key in complete_config:
+ specific_config[key] = complete_config.pop(key)
+ specific_config = await cls.async_validate_config(hass, specific_config)
+
+ for key in (CONF_OPTIONS, CONF_TARGET):
+ if key in specific_config:
+ complete_config[key] = specific_config[key]
+
+ return complete_config
@classmethod
@abc.abstractmethod
@@ -178,6 +210,9 @@ class Trigger(abc.ABC):
) -> ConfigType:
"""Validate config."""
+ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
+ """Initialize trigger."""
+
@abc.abstractmethod
async def async_attach(
self,
@@ -213,6 +248,15 @@ class TriggerProtocol(Protocol):
"""Attach a trigger."""
+@dataclass(slots=True, frozen=True)
+class TriggerConfig:
+ """Trigger config."""
+
+ key: str # The key used to identify the trigger, e.g. "zwave.event"
+ target: dict[str, Any] | None = None
+ options: dict[str, Any] | None = None
+
+
class TriggerActionType(Protocol):
"""Protocol type for trigger action callback."""
@@ -390,7 +434,7 @@ async def async_validate_trigger_config(
)
if not (trigger := trigger_descriptors.get(relative_trigger_key)):
raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified")
- conf = await trigger.async_validate_config(hass, conf)
+ conf = await trigger.async_validate_complete_config(hass, conf)
elif hasattr(platform, "async_validate_trigger_config"):
conf = await platform.async_validate_trigger_config(hass, conf)
else:
@@ -494,7 +538,15 @@ async def async_initialize_triggers(
relative_trigger_key = get_relative_description_key(
platform_domain, trigger_key
)
- trigger = trigger_descriptors[relative_trigger_key](hass, conf)
+ trigger_cls = trigger_descriptors[relative_trigger_key]
+ trigger = trigger_cls(
+ hass,
+ TriggerConfig(
+ key=trigger_key,
+ target=conf.get(CONF_TARGET),
+ options=conf.get(CONF_OPTIONS),
+ ),
+ )
coro = trigger.async_attach(action_wrapper, info)
else:
coro = platform.async_attach_trigger(hass, conf, action_wrapper, info)
@@ -537,7 +589,7 @@ def _load_triggers_file(integration: Integration) -> dict[str, Any]:
try:
return cast(
dict[str, Any],
- _TRIGGERS_SCHEMA(
+ _TRIGGERS_DESCRIPTION_SCHEMA(
load_yaml_dict(str(integration.file_path / "triggers.yaml"))
),
)
diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py
index d8ebab8b83e..46a50b184b5 100644
--- a/homeassistant/helpers/trigger_template_entity.py
+++ b/homeassistant/helpers/trigger_template_entity.py
@@ -37,10 +37,10 @@ from .template import (
_SENTINEL,
Template,
TemplateStateFromEntityId,
- _render_with_context,
render_complex,
result_as_boolean,
)
+from .template.context import render_with_context
from .typing import ConfigType
CONF_AVAILABILITY = "availability"
@@ -131,7 +131,7 @@ class ValueTemplate(Template):
compiled = self._compiled or self._ensure_compiled()
try:
- render_result = _render_with_context(
+ render_result = render_with_context(
self.template, compiled, **variables
).strip()
except jinja2.TemplateError as ex:
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 07c4a934573..fc10223a182 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -121,6 +121,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = {
"variable": BlockedIntegration(
AwesomeVersion("3.4.4"), "prevents recorder from working"
),
+ # Added in 2025.10.0 because of
+ # https://github.com/frenck/spook/issues/1066
+ "spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"),
}
DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey(
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 08fa913e563..c47ff2c605e 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -3,14 +3,14 @@
aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
aiodns==3.5.0
-aiohasupervisor==0.3.2b0
+aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.12.15
aiohttp_cors==0.8.1
aiousbwatcher==1.1.1
aiozoneinfo==0.2.3
-annotatedyaml==0.4.5
+annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
async-upnp-client==0.45.0
@@ -19,54 +19,55 @@ attrs==25.3.0
audioop-lts==0.2.1
av==13.1.0
awesomeversion==25.5.0
-bcrypt==4.3.0
-bleak-retry-connector==4.3.0
+bcrypt==5.0.0
+bleak-retry-connector==4.4.3
bleak==1.0.1
-bluetooth-adapters==2.0.0
-bluetooth-auto-recovery==1.5.2
-bluetooth-data-tools==1.28.2
-cached-ipaddress==0.10.0
+bluetooth-adapters==2.1.0
+bluetooth-auto-recovery==1.5.3
+bluetooth-data-tools==1.28.3
+cached-ipaddress==1.0.1
certifi>=2021.5.30
-ciso8601==2.3.2
+ciso8601==2.3.3
cronsim==2.6
-cryptography==45.0.3
-dbus-fast==2.44.3
-fnv-hash-fast==1.5.0
+cryptography==46.0.2
+dbus-fast==2.44.5
+file-read-backwards==2.0.0
+fnv-hash-fast==1.6.0
go2rtc-client==0.2.1
ha-ffmpeg==3.2.2
-habluetooth==5.1.0
-hass-nabucasa==1.0.0
-hassil==3.1.0
+habluetooth==5.7.0
+hass-nabucasa==1.2.0
+hassil==3.2.0
home-assistant-bluetooth==1.13.1
-home-assistant-frontend==20250811.1
-home-assistant-intents==2025.7.30
+home-assistant-frontend==20251001.0
+home-assistant-intents==2025.10.1
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.11.2
+orjson==3.11.3
packaging>=23.1
paho-mqtt==2.1.0
Pillow==11.3.0
-propcache==0.3.2
+propcache==0.4.0
psutil-home-assistant==0.0.1
PyJWT==2.10.1
pymicro-vad==1.0.1
-PyNaCl==1.5.0
-pyOpenSSL==25.1.0
+PyNaCl==1.6.0
+pyOpenSSL==25.3.0
pyserial==3.5
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.0
-PyYAML==6.0.2
-requests==2.32.4
+PyYAML==6.0.3
+requests==2.32.5
securetar==2025.2.1
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
-typing-extensions>=4.14.0,<5.0
-ulid-transform==1.4.0
+typing-extensions>=4.15.0,<5.0
+ulid-transform==1.5.2
urllib3>=2.0
uv==0.8.9
voluptuous-openapi==0.1.0
@@ -74,7 +75,7 @@ voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.20.1
-zeroconf==0.147.0
+zeroconf==0.148.0
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -87,9 +88,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.72.1
-grpcio-status==1.72.1
-grpcio-reflection==1.72.1
+grpcio==1.75.1
+grpcio-status==1.75.1
+grpcio-reflection==1.75.1
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -109,7 +110,7 @@ uuid==1000000000.0.0
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# requirements so we can directly link HA versions to these library versions.
-anyio==4.9.0
+anyio==4.10.0
h11==0.16.0
httpcore==1.0.9
@@ -119,7 +120,7 @@ hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
numpy==2.3.2
-pandas==2.3.0
+pandas==2.3.3
# Constrain multidict to avoid typing issues
# https://github.com/home-assistant/core/pull/67046
@@ -129,7 +130,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.11.7
+pydantic==2.11.9
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -219,7 +220,10 @@ num2words==0.5.14
# pymodbus does not follow SemVer, and it keeps getting
# downgraded or upgraded by custom components
# This ensures all use the same version
-pymodbus==3.11.1
+pymodbus==3.11.2
# Some packages don't support gql 4.0.0 yet
gql<4.0.0
+
+# Pin pytest-rerunfailures to prevent accidental breaks
+pytest-rerunfailures==16.0.1
diff --git a/homeassistant/runner.py b/homeassistant/runner.py
index abcf32f2659..6fa59923e81 100644
--- a/homeassistant/runner.py
+++ b/homeassistant/runner.py
@@ -3,10 +3,20 @@
from __future__ import annotations
import asyncio
+from collections.abc import Generator
+from contextlib import contextmanager
import dataclasses
+from datetime import datetime
+import fcntl
+from io import TextIOWrapper
+import json
import logging
+import os
+from pathlib import Path
import subprocess
+import sys
import threading
+import time
from time import monotonic
import traceback
from typing import Any
@@ -14,6 +24,7 @@ from typing import Any
import packaging.tags
from . import bootstrap
+from .const import __version__
from .core import callback
from .helpers.frame import warn_use
from .util.executor import InterruptibleThreadPoolExecutor
@@ -33,9 +44,113 @@ from .util.thread import deadlock_safe_shutdown
MAX_EXECUTOR_WORKERS = 64
TASK_CANCELATION_TIMEOUT = 5
+# Lock file configuration
+LOCK_FILE_NAME = ".ha_run.lock"
+LOCK_FILE_VERSION = 1 # Increment if format changes
+
_LOGGER = logging.getLogger(__name__)
+@dataclasses.dataclass
+class SingleExecutionLock:
+ """Context object for single execution lock."""
+
+ exit_code: int | None = None
+
+
+def _write_lock_info(lock_file: TextIOWrapper) -> None:
+ """Write current instance information to the lock file.
+
+ Args:
+ lock_file: The open lock file handle.
+ """
+ lock_file.seek(0)
+ lock_file.truncate()
+
+ instance_info = {
+ "pid": os.getpid(),
+ "version": LOCK_FILE_VERSION,
+ "ha_version": __version__,
+ "start_ts": time.time(),
+ }
+ json.dump(instance_info, lock_file)
+ lock_file.flush()
+
+
+def _report_existing_instance(lock_file_path: Path, config_dir: str) -> None:
+ """Report that another instance is already running.
+
+ Attempts to read the lock file to provide details about the running instance.
+ """
+ error_msg: list[str] = []
+ error_msg.append("Error: Another Home Assistant instance is already running!")
+
+ # Try to read information about the existing instance
+ try:
+ with open(lock_file_path, encoding="utf-8") as f:
+ if content := f.read().strip():
+ existing_info = json.loads(content)
+ start_dt = datetime.fromtimestamp(existing_info["start_ts"])
+ # Format with timezone abbreviation if available, otherwise add local time indicator
+ if tz_abbr := start_dt.strftime("%Z"):
+ start_time = start_dt.strftime(f"%Y-%m-%d %H:%M:%S {tz_abbr}")
+ else:
+ start_time = (
+ start_dt.strftime("%Y-%m-%d %H:%M:%S") + " (local time)"
+ )
+
+ error_msg.append(f" PID: {existing_info['pid']}")
+ error_msg.append(f" Version: {existing_info['ha_version']}")
+ error_msg.append(f" Started: {start_time}")
+ else:
+ error_msg.append(" Unable to read lock file details.")
+ except (json.JSONDecodeError, OSError) as ex:
+ error_msg.append(f" Unable to read lock file details: {ex}")
+
+ error_msg.append(f" Config directory: {config_dir}")
+ error_msg.append("")
+ error_msg.append("Please stop the existing instance before starting a new one.")
+
+ for line in error_msg:
+ print(line, file=sys.stderr) # noqa: T201
+
+
+@contextmanager
+def ensure_single_execution(config_dir: str) -> Generator[SingleExecutionLock]:
+ """Ensure only one Home Assistant instance runs per config directory.
+
+ Uses file locking to prevent multiple instances from running with the
+ same configuration directory, which can cause data corruption.
+
+ Returns a context object with exit_code attribute that will be set
+ if another instance is already running.
+ """
+ lock_file_path = Path(config_dir) / LOCK_FILE_NAME
+ lock_context = SingleExecutionLock()
+
+ # Open with 'a+' mode to avoid truncating existing content
+ # This allows us to read existing content if lock fails
+ with open(lock_file_path, "a+", encoding="utf-8") as lock_file:
+ # Try to acquire an exclusive, non-blocking lock
+ # This will raise BlockingIOError if lock is already held
+ try:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ # Another instance is already running
+ _report_existing_instance(lock_file_path, config_dir)
+ lock_context.exit_code = 1
+ yield lock_context
+ return # Exit early since we couldn't get the lock
+
+ # If we got the lock (no exception), write our instance info
+ _write_lock_info(lock_file)
+
+ # Yield the context - lock will be released when the with statement closes the file
+ # IMPORTANT: We don't unlink the file to avoid races where multiple processes
+ # could create different lock files
+ yield lock_context
+
+
@dataclasses.dataclass(slots=True)
class RuntimeConfig:
"""Class to hold the information for running Home Assistant."""
diff --git a/homeassistant/strings.json b/homeassistant/strings.json
index 8e232498177..dbd8b789e27 100644
--- a/homeassistant/strings.json
+++ b/homeassistant/strings.json
@@ -53,7 +53,7 @@
"email": "Email",
"host": "Host",
"ip": "IP address",
- "implementation": "Application Credentials",
+ "implementation": "Application credentials",
"language": "Language",
"latitude": "Latitude",
"llm_hass_api": "Control Home Assistant",
diff --git a/homeassistant/util/async_iterator.py b/homeassistant/util/async_iterator.py
new file mode 100644
index 00000000000..b59d8b47416
--- /dev/null
+++ b/homeassistant/util/async_iterator.py
@@ -0,0 +1,134 @@
+"""Async iterator utilities."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from concurrent.futures import CancelledError, Future
+from typing import Self
+
+
+class Abort(Exception):
+ """Raised when abort is requested."""
+
+
+class AsyncIteratorReader:
+ """Allow reading from an AsyncIterator using blocking I/O.
+
+ The class implements a blocking read method reading from the async iterator,
+ and a close method.
+
+ In addition, the abort method can be used to abort any ongoing read operation.
+ """
+
+ def __init__(
+ self,
+ loop: asyncio.AbstractEventLoop,
+ stream: AsyncIterator[bytes],
+ ) -> None:
+ """Initialize the wrapper."""
+ self._aborted = False
+ self._loop = loop
+ self._stream = stream
+ self._buffer: bytes | None = None
+ self._next_future: Future[bytes | None] | None = None
+ self._pos: int = 0
+
+ async def _next(self) -> bytes | None:
+ """Get the next chunk from the iterator."""
+ return await anext(self._stream, None)
+
+ def abort(self) -> None:
+ """Abort the reader."""
+ self._aborted = True
+ if self._next_future is not None:
+ self._next_future.cancel()
+
+ def read(self, n: int = -1, /) -> bytes:
+ """Read up to n bytes of data from the iterator.
+
+ The read method returns 0 bytes when the iterator is exhausted.
+ """
+ result = bytearray()
+ while n < 0 or len(result) < n:
+ if not self._buffer:
+ self._next_future = asyncio.run_coroutine_threadsafe(
+ self._next(), self._loop
+ )
+ if self._aborted:
+ self._next_future.cancel()
+ raise Abort
+ try:
+ self._buffer = self._next_future.result()
+ except CancelledError as err:
+ raise Abort from err
+ self._pos = 0
+ if not self._buffer:
+ # The stream is exhausted
+ break
+ chunk = self._buffer[self._pos : self._pos + n]
+ result.extend(chunk)
+ n -= len(chunk)
+ self._pos += len(chunk)
+ if self._pos == len(self._buffer):
+ self._buffer = None
+ return bytes(result)
+
+ def close(self) -> None:
+ """Close the iterator."""
+
+
+class AsyncIteratorWriter:
+ """Allow writing to an AsyncIterator using blocking I/O.
+
+ The class implements a blocking write method writing to the async iterator,
+ as well as a close and tell methods.
+
+ In addition, the abort method can be used to abort any ongoing write operation.
+ """
+
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
+ """Initialize the wrapper."""
+ self._aborted = False
+ self._loop = loop
+ self._pos: int = 0
+ self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
+ self._write_future: Future[bytes | None] | None = None
+
+ def __aiter__(self) -> Self:
+ """Return the iterator."""
+ return self
+
+ async def __anext__(self) -> bytes:
+ """Get the next chunk from the iterator."""
+ if data := await self._queue.get():
+ return data
+ raise StopAsyncIteration
+
+ def abort(self) -> None:
+ """Abort the writer."""
+ self._aborted = True
+ if self._write_future is not None:
+ self._write_future.cancel()
+
+ def tell(self) -> int:
+ """Return the current position in the iterator."""
+ return self._pos
+
+ def write(self, s: bytes, /) -> int:
+ """Write data to the iterator.
+
+ To signal the end of the stream, write a zero-length bytes object.
+ """
+ self._write_future = asyncio.run_coroutine_threadsafe(
+ self._queue.put(s), self._loop
+ )
+ if self._aborted:
+ self._write_future.cancel()
+ raise Abort
+ try:
+ self._write_future.result()
+ except CancelledError as err:
+ raise Abort from err
+ self._pos += len(s)
+ return len(s)
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 4d6d2365617..dba858c07bf 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -82,6 +82,7 @@ _STONE_TO_G = _POUND_TO_G * 14 # 14 pounds to a stone
# Pressure conversion constants
_STANDARD_GRAVITY = 9.80665
_MERCURY_DENSITY = 13.5951
+_INH2O_TO_PA = 249.0889083333348 # 1 inH₂O = 249.0889083333348 Pa at 4°C
# Volume conversion constants
_L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³
@@ -391,10 +392,12 @@ class ApparentPowerConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000,
UnitOfApparentPower.VOLT_AMPERE: 1,
+ UnitOfApparentPower.KILO_VOLT_AMPERE: 1 / 1000,
}
VALID_UNITS = {
UnitOfApparentPower.MILLIVOLT_AMPERE,
UnitOfApparentPower.VOLT_AMPERE,
+ UnitOfApparentPower.KILO_VOLT_AMPERE,
}
@@ -409,6 +412,7 @@ class PowerConverter(BaseUnitConverter):
UnitOfPower.MEGA_WATT: 1 / 1e6,
UnitOfPower.GIGA_WATT: 1 / 1e9,
UnitOfPower.TERA_WATT: 1 / 1e12,
+ UnitOfPower.BTU_PER_HOUR: 1 / 0.29307107,
}
VALID_UNITS = {
UnitOfPower.MILLIWATT,
@@ -417,6 +421,7 @@ class PowerConverter(BaseUnitConverter):
UnitOfPower.MEGA_WATT,
UnitOfPower.GIGA_WATT,
UnitOfPower.TERA_WATT,
+ UnitOfPower.BTU_PER_HOUR,
}
@@ -433,6 +438,7 @@ class PressureConverter(BaseUnitConverter):
UnitOfPressure.MBAR: 1 / 100,
UnitOfPressure.INHG: 1
/ (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
+ UnitOfPressure.INH2O: 1 / _INH2O_TO_PA,
UnitOfPressure.PSI: 1 / 6894.757,
UnitOfPressure.MMHG: 1
/ (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
@@ -445,6 +451,7 @@ class PressureConverter(BaseUnitConverter):
UnitOfPressure.CBAR,
UnitOfPressure.MBAR,
UnitOfPressure.INHG,
+ UnitOfPressure.INH2O,
UnitOfPressure.PSI,
UnitOfPressure.MMHG,
}
@@ -490,6 +497,7 @@ class SpeedConverter(BaseUnitConverter):
UnitOfSpeed.INCHES_PER_SECOND: 1 / _IN_TO_M,
UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M,
UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M,
+ UnitOfSpeed.METERS_PER_MINUTE: _MIN_TO_SEC,
UnitOfSpeed.METERS_PER_SECOND: 1,
UnitOfSpeed.MILLIMETERS_PER_SECOND: 1 / _MM_TO_M,
UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M,
@@ -504,6 +512,7 @@ class SpeedConverter(BaseUnitConverter):
UnitOfSpeed.FEET_PER_SECOND,
UnitOfSpeed.KILOMETERS_PER_HOUR,
UnitOfSpeed.KNOTS,
+ UnitOfSpeed.METERS_PER_MINUTE,
UnitOfSpeed.METERS_PER_SECOND,
UnitOfSpeed.MILES_PER_HOUR,
UnitOfSpeed.MILLIMETERS_PER_SECOND,
@@ -717,6 +726,8 @@ class UnitlessRatioConverter(BaseUnitConverter):
}
VALID_UNITS = {
None,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
}
@@ -750,6 +761,7 @@ class VolumeConverter(BaseUnitConverter):
UnitOfVolume.CUBIC_METERS: 1,
UnitOfVolume.CUBIC_FEET: 1 / _CUBIC_FOOT_TO_CUBIC_METER,
UnitOfVolume.CENTUM_CUBIC_FEET: 1 / (100 * _CUBIC_FOOT_TO_CUBIC_METER),
+ UnitOfVolume.MILLE_CUBIC_FEET: 1 / (1000 * _CUBIC_FOOT_TO_CUBIC_METER),
}
VALID_UNITS = {
UnitOfVolume.LITERS,
@@ -759,6 +771,7 @@ class VolumeConverter(BaseUnitConverter):
UnitOfVolume.CUBIC_METERS,
UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CENTUM_CUBIC_FEET,
+ UnitOfVolume.MILLE_CUBIC_FEET,
}
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 31f74377a16..3268520e3f6 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -281,6 +281,7 @@ METRIC_SYSTEM = UnitSystem(
# Convert non-metric volumes of gas meters
("gas", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
("gas", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
+ ("gas", UnitOfVolume.MILLE_CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
# Convert non-metric precipitation
("precipitation", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS,
# Convert non-metric precipitation intensity
@@ -295,6 +296,7 @@ METRIC_SYSTEM = UnitSystem(
# Convert non-metric pressure
("pressure", UnitOfPressure.PSI): UnitOfPressure.KPA,
("pressure", UnitOfPressure.INHG): UnitOfPressure.HPA,
+ ("pressure", UnitOfPressure.INH2O): UnitOfPressure.KPA,
# Convert non-metric speeds except knots to km/h
("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR,
("speed", UnitOfSpeed.INCHES_PER_SECOND): UnitOfSpeed.MILLIMETERS_PER_SECOND,
@@ -312,10 +314,12 @@ METRIC_SYSTEM = UnitSystem(
("volume", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
("volume", UnitOfVolume.FLUID_OUNCES): UnitOfVolume.MILLILITERS,
("volume", UnitOfVolume.GALLONS): UnitOfVolume.LITERS,
+ ("volume", UnitOfVolume.MILLE_CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
# Convert non-metric volumes of water meters
("water", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS,
+ ("water", UnitOfVolume.MILLE_CUBIC_FEET): UnitOfVolume.CUBIC_METERS,
# Convert wind speeds except knots to km/h
**{
("wind_speed", unit): UnitOfSpeed.KILOMETERS_PER_HOUR
@@ -376,7 +380,9 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI,
("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI,
("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG,
+ ("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI,
# Convert non-USCS speeds, except knots, to mph
+ ("speed", UnitOfSpeed.METERS_PER_MINUTE): UnitOfSpeed.INCHES_PER_SECOND,
("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR,
("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND,
("speed", UnitOfSpeed.KILOMETERS_PER_HOUR): UnitOfSpeed.MILES_PER_HOUR,
diff --git a/mypy.ini b/mypy.ini
index ad9196c80c5..81776140629 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1175,6 +1175,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.compit.*]
+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.config.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1446,6 +1456,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.droplet.*]
+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.dsmr.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1766,6 +1786,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.firefly_iii.*]
+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.fitbit.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2826,6 +2856,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.libre_hardware_monitor.*]
+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.lidarr.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2976,6 +3016,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.lunatone.*]
+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.madvr.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3576,6 +3626,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.opnsense.*]
+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.opower.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3746,6 +3806,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.portainer.*]
+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.powerfox.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4136,6 +4206,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.route_b_smart_meter.*]
+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.rpi_power.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4336,6 +4416,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.sftp_storage.*]
+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.shell_command.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -5219,6 +5309,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.vivotek.*]
+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.vlc_telnet.*]
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 82118209e65..4b22d1284d7 100644
--- a/pylint/plugins/hass_enforce_type_hints.py
+++ b/pylint/plugins/hass_enforce_type_hints.py
@@ -3425,6 +3425,14 @@ class HassTypeHintChecker(BaseChecker):
# Check that all positional arguments are correctly annotated.
if match.arg_types:
for key, expected_type in match.arg_types.items():
+ if key > len(node.args.args) - 1:
+ # The number of arguments is less than expected
+ self.add_message(
+ "hass-argument-type",
+ node=node,
+ args=(key + 1, expected_type, node.name),
+ )
+ continue
if node.args.args[key].name in _COMMON_ARGUMENTS:
# It has already been checked, avoid double-message
continue
diff --git a/pyproject.toml b/pyproject.toml
index 4ed99327499..3ce2b9a4c64 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2025.9.0.dev0"
+version = "2025.11.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -27,27 +27,27 @@ dependencies = [
# 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
- "aiohasupervisor==0.3.2b0",
+ "aiohasupervisor==0.3.3",
"aiohttp==3.12.15",
"aiohttp_cors==0.8.1",
"aiohttp-fast-zlib==0.3.0",
"aiohttp-asyncmdnsresolver==0.1.1",
"aiozoneinfo==0.2.3",
- "annotatedyaml==0.4.5",
+ "annotatedyaml==1.0.2",
"astral==2.2",
"async-interrupt==1.2.2",
"attrs==25.3.0",
"atomicwrites-homeassistant==1.4.1",
"audioop-lts==0.2.1",
"awesomeversion==25.5.0",
- "bcrypt==4.3.0",
+ "bcrypt==5.0.0",
"certifi>=2021.5.30",
- "ciso8601==2.3.2",
+ "ciso8601==2.3.3",
"cronsim==2.6",
- "fnv-hash-fast==1.5.0",
+ "fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==1.0.0",
+ "hass-nabucasa==1.2.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",
@@ -57,22 +57,22 @@ dependencies = [
"lru-dict==1.3.0",
"PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
- "cryptography==45.0.3",
+ "cryptography==46.0.2",
"Pillow==11.3.0",
- "propcache==0.3.2",
- "pyOpenSSL==25.1.0",
- "orjson==3.11.2",
+ "propcache==0.4.0",
+ "pyOpenSSL==25.3.0",
+ "orjson==3.11.3",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
- "PyYAML==6.0.2",
- "requests==2.32.4",
+ "PyYAML==6.0.3",
+ "requests==2.32.5",
"securetar==2025.2.1",
"SQLAlchemy==2.0.41",
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",
- "typing-extensions>=4.14.0,<5.0",
- "ulid-transform==1.4.0",
+ "typing-extensions>=4.15.0,<5.0",
+ "ulid-transform==1.5.2",
"urllib3>=2.0",
"uv==0.8.9",
"voluptuous==0.15.2",
@@ -80,7 +80,7 @@ dependencies = [
"voluptuous-openapi==0.1.0",
"yarl==1.20.1",
"webrtc-models==0.3.0",
- "zeroconf==0.147.0",
+ "zeroconf==0.148.0",
]
[project.urls]
@@ -448,6 +448,7 @@ testpaths = ["tests"]
norecursedirs = [".git", "testing_config"]
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
+asyncio_debug = true
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
@@ -468,7 +469,7 @@ filterwarnings = [
"ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic",
# -- DeprecationWarning already fixed in our codebase
- # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11
+ # https://github.com/kurtmckee/feedparser/ - 6.0.12
"ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util",
# -- design choice 3rd party
@@ -569,9 +570,6 @@ filterwarnings = [
"ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap",
# -- New in Python 3.13
- # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11
- # https://github.com/kurtmckee/feedparser/issues/481
- "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html",
# https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib
"ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition",
"ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor",
@@ -640,7 +638,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.12.1"
+required-version = ">=0.13.0"
[tool.ruff.lint]
select = [
@@ -710,6 +708,7 @@ select = [
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
+ "RUF059", # unused-unpacked-variable
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
@@ -782,8 +781,7 @@ ignore = [
"TRY003", # Avoid specifying long messages outside the exception class
"TRY400", # Use `logging.exception` instead of `logging.error`
- # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
- "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
+
"UP046", # Non PEP 695 generic class
"UP047", # Non PEP 696 generic function
"UP049", # Avoid private type parameter names
@@ -801,6 +799,8 @@ ignore = [
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605",
+
+ "FURB116"
]
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
diff --git a/requirements.txt b/requirements.txt
index e94f0c3caea..d10b789c4e3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,47 +4,47 @@
# Home Assistant Core
aiodns==3.5.0
-aiohasupervisor==0.3.2b0
+aiohasupervisor==0.3.3
aiohttp==3.12.15
aiohttp_cors==0.8.1
aiohttp-fast-zlib==0.3.0
aiohttp-asyncmdnsresolver==0.1.1
aiozoneinfo==0.2.3
-annotatedyaml==0.4.5
+annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
attrs==25.3.0
atomicwrites-homeassistant==1.4.1
audioop-lts==0.2.1
awesomeversion==25.5.0
-bcrypt==4.3.0
+bcrypt==5.0.0
certifi>=2021.5.30
-ciso8601==2.3.2
+ciso8601==2.3.3
cronsim==2.6
-fnv-hash-fast==1.5.0
-hass-nabucasa==1.0.0
+fnv-hash-fast==1.6.0
+hass-nabucasa==1.2.0
httpx==0.28.1
home-assistant-bluetooth==1.13.1
ifaddr==0.2.0
Jinja2==3.1.6
lru-dict==1.3.0
PyJWT==2.10.1
-cryptography==45.0.3
+cryptography==46.0.2
Pillow==11.3.0
-propcache==0.3.2
-pyOpenSSL==25.1.0
-orjson==3.11.2
+propcache==0.4.0
+pyOpenSSL==25.3.0
+orjson==3.11.3
packaging>=23.1
psutil-home-assistant==0.0.1
python-slugify==8.0.4
-PyYAML==6.0.2
-requests==2.32.4
+PyYAML==6.0.3
+requests==2.32.5
securetar==2025.2.1
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
-typing-extensions>=4.14.0,<5.0
-ulid-transform==1.4.0
+typing-extensions>=4.15.0,<5.0
+ulid-transform==1.5.2
urllib3>=2.0
uv==0.8.9
voluptuous==0.15.2
@@ -52,4 +52,4 @@ voluptuous-serialize==2.7.0
voluptuous-openapi==0.1.0
yarl==1.20.1
webrtc-models==0.3.0
-zeroconf==0.147.0
+zeroconf==0.148.0
diff --git a/requirements_all.txt b/requirements_all.txt
index ef2ba5a5fbe..44a096e5fbd 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -16,10 +16,10 @@ Adax-local==0.1.5
DoorBirdPy==3.0.8
# homeassistant.components.homekit
-HAP-python==4.9.2
+HAP-python==5.0.0
# homeassistant.components.tasmota
-HATasmota==0.10.0
+HATasmota==0.10.1
# homeassistant.components.mastodon
Mastodon.py==2.1.2
@@ -74,7 +74,7 @@ PyMicroBot==0.0.23
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
-PyNaCl==1.5.0
+PyNaCl==1.6.0
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.homekit
@@ -84,7 +84,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.69.0
+PySwitchbot==0.71.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -100,7 +100,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
-PyViCare==2.50.0
+PyViCare==2.52.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -131,7 +131,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.1.2
# homeassistant.components.accuweather
-accuweather==4.2.0
+accuweather==4.2.2
# homeassistant.components.adax
adax==0.4.0
@@ -173,26 +173,26 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.14
+aioacaia==0.1.17
# homeassistant.components.airq
aioairq==0.4.6
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.7.1
+aioairzone-cloud==0.7.2
# homeassistant.components.airzone
-aioairzone==1.0.0
+aioairzone==1.0.1
# homeassistant.components.alexa_devices
-aioamazondevices==5.0.0
+aioamazondevices==6.2.9
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
aioambient==2024.08.0
# homeassistant.components.apcupsd
-aioapcaccess==0.4.2
+aioapcaccess==1.0.0
# homeassistant.components.aquacell
aioaquacell==0.2.0
@@ -204,7 +204,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2.1.2
+aioautomower==2.2.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -238,16 +238,16 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2025.3.1
+aioecowitt==2025.9.2
# homeassistant.components.co2signal
-aioelectricitymaps==0.4.0
+aioelectricitymaps==1.1.1
# homeassistant.components.emonitor
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==39.0.0
+aioesphomeapi==41.12.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -262,22 +262,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.5.2
+aioharmony==0.5.3
# homeassistant.components.hassio
-aiohasupervisor==0.3.2b0
+aiohasupervisor==0.3.3
# homeassistant.components.home_connect
-aiohomeconnect==0.18.1
+aiohomeconnect==0.20.0
# homeassistant.components.homekit_controller
-aiohomekit==3.2.15
+aiohomekit==3.2.20
# homeassistant.components.mcp_server
aiohttp_sse==2.2.0
# homeassistant.components.hue
-aiohue==4.7.4
+aiohue==4.8.0
# homeassistant.components.imap
aioimaplib==2.0.1
@@ -298,7 +298,7 @@ aiokem==1.0.1
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.6.4
+aiolifx-themes==1.0.2
# homeassistant.components.lifx
aiolifx==1.2.1
@@ -310,7 +310,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
-aiomealie==0.10.1
+aiomealie==1.0.0
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -325,7 +325,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
-aiontfy==0.5.4
+aiontfy==0.6.1
# homeassistant.components.nut
aionut==4.3.4
@@ -346,10 +346,10 @@ aiopegelonline==0.1.1
aiopulse==0.4.6
# homeassistant.components.purpleair
-aiopurpleair==2023.12.0
+aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview
-aiopvapi==3.1.1
+aiopvapi==3.2.1
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2
@@ -369,13 +369,13 @@ aioraven==0.7.1
aiorecollect==2023.09.0
# homeassistant.components.ridwell
-aioridwell==2024.01.0
+aioridwell==2025.09.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.8.1
+aiorussound==4.8.2
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -384,7 +384,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==13.8.0
+aioshelly==13.11.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -417,7 +417,7 @@ aiotedee==0.2.25
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==86
+aiounifi==87
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -426,7 +426,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
-aiovodafone==0.10.0
+aiovodafone==1.2.1
# homeassistant.components.waqi
aiowaqi==3.1.0
@@ -453,10 +453,10 @@ airgradient==0.9.2
airly==1.1.0
# homeassistant.components.airos
-airos==0.4.3
+airos==0.5.5
# homeassistant.components.airthings_ble
-airthings-ble==0.9.2
+airthings-ble==1.1.1
# homeassistant.components.airthings
airthings-cloud==0.2.0
@@ -495,10 +495,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.62.0
+anthropic==0.69.0
# homeassistant.components.mcp_server
-anyio==4.9.0
+anyio==4.10.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -528,7 +528,7 @@ arris-tg2492lg==2.2.0
asmog==0.0.6
# homeassistant.components.asuswrt
-asusrouter==1.20.0
+asusrouter==1.21.0
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
@@ -550,6 +550,9 @@ asyncpysupla==0.0.5
# homeassistant.components.sleepiq
asyncsleepiq==1.6.0
+# homeassistant.components.sftp_storage
+asyncssh==2.21.0
+
# homeassistant.components.aten_pe
# atenpdu==0.3.2
@@ -618,17 +621,16 @@ beautifulsoup4==4.13.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.17.2
+bimmer-connected[china]==0.17.3
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
-# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==3.1.0
+bleak-esphome==3.4.0
# homeassistant.components.bluetooth
-bleak-retry-connector==4.3.0
+bleak-retry-connector==4.4.3
# homeassistant.components.bluetooth
bleak==1.0.1
@@ -652,16 +654,16 @@ bluemaestro-ble==0.4.1
# bluepy==1.3.0
# homeassistant.components.bluetooth
-bluetooth-adapters==2.0.0
+bluetooth-adapters==2.1.0
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.5.2
+bluetooth-auto-recovery==1.5.3
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.28.2
+bluetooth-data-tools==1.28.3
# homeassistant.components.bond
bond-async==0.2.1
@@ -686,7 +688,7 @@ bring-api==1.1.0
broadlink==0.19.0
# homeassistant.components.brother
-brother==5.0.1
+brother==5.1.0
# homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5
@@ -698,7 +700,7 @@ brunt==1.2.0
bt-proximity==0.2.1
# homeassistant.components.bthome
-bthome-ble==3.13.1
+bthome-ble==3.14.2
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -710,7 +712,7 @@ btsmarthub-devicelist==0.2.3
buienradar==1.0.6
# homeassistant.components.dhcp
-cached-ipaddress==0.10.0
+cached-ipaddress==1.0.1
# homeassistant.components.caldav
caldav==1.6.0
@@ -733,6 +735,9 @@ colorlog==6.9.0
# homeassistant.components.color_extractor
colorthief==0.2.1
+# homeassistant.components.compit
+compit-inext-api==0.3.1
+
# homeassistant.components.concord232
concord232==0.15.1
@@ -765,10 +770,10 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
-dbus-fast==2.44.3
+dbus-fast==2.44.5
# homeassistant.components.debugpy
-debugpy==1.8.14
+debugpy==1.8.16
# homeassistant.components.decora_wifi
decora-wifi==1.4
@@ -777,7 +782,7 @@ decora-wifi==1.4
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==13.6.0
+deebot-client==15.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -847,6 +852,9 @@ ecoaliface==0.4.0
# homeassistant.components.eheimdigital
eheimdigital==1.3.0
+# homeassistant.components.ekeybionyx
+ekey-bionyxpy==1.0.0
+
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@@ -902,7 +910,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
-eq3btsmart==2.1.0
+eq3btsmart==2.3.0
# homeassistant.components.esphome
esphome-dashboard-api==1.3.0
@@ -933,7 +941,7 @@ faadelays==2023.9.1
fastdotcom==0.0.3
# homeassistant.components.feedreader
-feedparser==6.0.11
+feedparser==6.0.12
# homeassistant.components.file
file-read-backwards==2.0.0
@@ -951,7 +959,7 @@ fivem-api==0.1.2
fixerio==1.0.0a0
# homeassistant.components.fjaraskupan
-fjaraskupan==2.3.2
+fjaraskupan==2.3.3
# homeassistant.components.flexit_bacnet
flexit_bacnet==2.2.3
@@ -967,7 +975,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.5.0
+fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -986,7 +994,7 @@ freesms==0.2.0
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
-fritzconnection[qr]==1.14.0
+fritzconnection[qr]==1.15.0
# homeassistant.components.fyta
fyta_cli==0.7.2
@@ -995,6 +1003,7 @@ fyta_cli==0.7.2
gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
+# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
@@ -1003,6 +1012,9 @@ gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==8.0.0
+# homeassistant.components.aladdin_connect
+genie-partner-sdk==1.0.11
+
# homeassistant.components.geniushub
geniushub-client==0.7.1
@@ -1060,7 +1072,7 @@ google-cloud-speech==2.31.1
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-genai==1.29.0
+google-genai==1.38.0
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -1082,7 +1094,7 @@ gotailwind==0.3.0
govee-ble==0.44.0
# homeassistant.components.govee_light_local
-govee-local-api==2.1.0
+govee-local-api==2.2.0
# homeassistant.components.remote_rpi_gpio
gpiozero==1.6.2
@@ -1115,7 +1127,7 @@ gstreamer-player==1.1.2
guppy3==3.1.5
# homeassistant.components.iaqualink
-h2==4.2.0
+h2==4.3.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.2.2
@@ -1124,26 +1136,26 @@ ha-ffmpeg==3.2.2
ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
-ha-philipsjs==3.2.2
+ha-philipsjs==3.2.4
# homeassistant.components.homeassistant_hardware
ha-silabs-firmware-client==0.2.0
# homeassistant.components.habitica
-habiticalib==0.4.3
+habiticalib==0.4.5
# homeassistant.components.bluetooth
-habluetooth==5.1.0
+habluetooth==5.7.0
# homeassistant.components.cloud
-hass-nabucasa==1.0.0
+hass-nabucasa==1.2.0
# homeassistant.components.splunk
hass-splunk==0.1.1
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
-hassil==3.1.0
+hassil==3.2.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.2
@@ -1174,16 +1186,16 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.79
+holidays==0.81
# homeassistant.components.frontend
-home-assistant-frontend==20250811.1
+home-assistant-frontend==20251001.0
# homeassistant.components.conversation
-home-assistant-intents==2025.7.30
+home-assistant-intents==2025.10.1
# homeassistant.components.homematicip_cloud
-homematicip==2.2.0
+homematicip==2.3.0
# homeassistant.components.horizon
horimote==0.4.1
@@ -1204,14 +1216,11 @@ hyperion-py==0.7.6
iammeter==0.2.1
# homeassistant.components.iaqualink
-iaqualink==0.5.3
+iaqualink==0.6.0
# homeassistant.components.ibeacon
ibeacon-ble==1.2.0
-# homeassistant.components.watson_iot
-ibmiotf==0.3.4
-
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
@@ -1240,10 +1249,10 @@ igloohome-api==0.1.1
ihcsdk==2.8.5
# homeassistant.components.imeon_inverter
-imeon_inverter_api==0.3.14
+imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
-imgw_pib==1.5.4
+imgw_pib==1.5.6
# homeassistant.components.incomfort
incomfort-client==0.6.9
@@ -1272,8 +1281,11 @@ iottycloud==0.3.0
# homeassistant.components.iperf3
iperf3==0.1.11
+# homeassistant.components.irm_kmi
+irm-kmi-api==1.1.0
+
# homeassistant.components.isal
-isal==1.7.1
+isal==1.8.0
# homeassistant.components.gogogate2
ismartgate==5.0.2
@@ -1349,7 +1361,10 @@ letpot==0.6.2
libpyfoscamcgi==0.0.7
# homeassistant.components.vivotek
-libpyvivotek==0.4.0
+libpyvivotek==0.6.1
+
+# homeassistant.components.libre_hardware_monitor
+librehardwaremonitor-api==1.4.0
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1384,6 +1399,9 @@ loqedAPI==2.1.10
# homeassistant.components.luftdaten
luftdaten==0.7.4
+# homeassistant.components.lunatone
+lunatone-rest-api-client==0.4.8
+
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1404,7 +1422,7 @@ mbddns==0.1.2
# homeassistant.components.mcp
# homeassistant.components.mcp_server
-mcp==1.5.0
+mcp==1.14.1
# homeassistant.components.minecraft_server
mcstatus==12.0.1
@@ -1421,6 +1439,9 @@ melnor-bluetooth==0.0.25
# homeassistant.components.message_bird
messagebird==1.2.0
+# homeassistant.components.meteo_lt
+meteo-lt-pkg==0.2.4
+
# homeassistant.components.meteoalarm
meteoalertapi==0.3.1
@@ -1440,7 +1461,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.12.5
+millheater==0.14.0
# homeassistant.components.minio
minio==7.1.12
@@ -1451,6 +1472,9 @@ moat-ble==0.1.1
# homeassistant.components.moehlenhoff_alpha2
moehlenhoff-alpha2==1.4.0
+# homeassistant.components.route_b_smart_meter
+momonga==0.1.5
+
# homeassistant.components.monzo
monzopy==1.5.1
@@ -1481,6 +1505,9 @@ mutagen==1.47.0
# homeassistant.components.mutesync
mutesync==0.0.1
+# homeassistant.components.mvglive
+mvg==1.4.0
+
# homeassistant.components.permobil
mypermobil==0.1.8
@@ -1494,7 +1521,7 @@ nad-receiver==0.3.0
ndms2-client==0.1.2
# homeassistant.components.ness_alarm
-nessclient==1.2.0
+nessclient==1.3.1
# homeassistant.components.netdata
netdata==1.3.0
@@ -1509,7 +1536,7 @@ nettigo-air-monitor==5.0.0
neurio==0.3.1
# homeassistant.components.nexia
-nexia==2.10.0
+nexia==2.11.1
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1524,7 +1551,7 @@ nextdns==4.1.0
nhc==0.4.12
# homeassistant.components.nibe_heatpump
-nibe==2.17.0
+nibe==2.19.0
# homeassistant.components.nice_go
nice-go==1.0.1
@@ -1579,7 +1606,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
-ohme==1.5.1
+ohme==1.5.2
# homeassistant.components.ollama
ollama==0.5.1
@@ -1628,7 +1655,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.15.2
+opower==0.15.6
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1643,10 +1670,10 @@ orvibo==1.1.2
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
-ovoenergy==2.0.1
+ovoenergy==3.0.2
# homeassistant.components.p1_monitor
-p1monitor==3.1.0
+p1monitor==3.2.0
# homeassistant.components.mqtt
paho-mqtt==2.1.0
@@ -1698,9 +1725,6 @@ plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.7.8
-# homeassistant.components.plum_lightpad
-plumlightpad==0.0.11
-
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -1722,6 +1746,9 @@ proliphix==0.4.1
# homeassistant.components.prometheus
prometheus-client==0.21.0
+# homeassistant.components.prowl
+prowlpy==1.0.2
+
# homeassistant.components.proxmoxve
proxmoxer==2.0.1
@@ -1746,14 +1773,11 @@ pushover_complete==1.2.0
pvo==2.2.1
# homeassistant.components.aosmith
-py-aosmith==1.0.12
+py-aosmith==1.0.14
# homeassistant.components.canary
py-canary==0.5.4
-# homeassistant.components.ccm15
-py-ccm15==0.0.9
-
# homeassistant.components.cpuspeed
py-cpuinfo==9.0.0
@@ -1809,7 +1833,7 @@ pyEmby==1.10
pyHik==0.3.2
# homeassistant.components.homee
-pyHomee==1.2.10
+pyHomee==1.3.8
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1818,7 +1842,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
-pyTibber==0.31.6
+pyTibber==0.32.2
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1826,6 +1850,9 @@ pyW215==0.8.0
# homeassistant.components.w800rf32
pyW800rf32==0.4
+# homeassistant.components.ccm15
+py_ccm15==0.1.2
+
# homeassistant.components.ads
pyads==3.4.0
@@ -1867,7 +1894,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==2.0.4
+pyblu==2.0.5
# homeassistant.components.neato
pybotvac==0.0.28
@@ -1908,8 +1935,11 @@ pycsspeechtts==1.0.8
# homeassistant.components.cups
# pycups==2.0.4
+# homeassistant.components.cync
+pycync==0.4.1
+
# homeassistant.components.daikin
-pydaikin==2.16.0
+pydaikin==2.17.1
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -1933,11 +1963,14 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2025.7.0
+pydrawise==2025.9.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
+# homeassistant.components.droplet
+pydroplet==2.3.3
+
# homeassistant.components.ebox
pyebox==1.1.4
@@ -1948,7 +1981,7 @@ pyecoforest==0.4.0
pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
-pyecotrend-ista==3.3.1
+pyecotrend-ista==3.4.0
# homeassistant.components.edimax
pyedimax==0.2.1
@@ -1961,10 +1994,10 @@ pyegps==0.2.5
# homeassistant.components.emoncms
# homeassistant.components.emoncms_history
-pyemoncms==0.1.2
+pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
-pyenphase==2.3.0
+pyenphase==2.4.0
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1987,6 +2020,9 @@ pyfibaro==0.8.3
# homeassistant.components.fido
pyfido==2.1.2
+# homeassistant.components.firefly_iii
+pyfirefly==0.1.6
+
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -2036,7 +2072,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
-pyicloud==1.0.0
+pyicloud==2.0.3
# homeassistant.components.insteon
pyinsteon==1.6.3
@@ -2057,7 +2093,7 @@ pyiqvia==2022.04.0
pyirishrail==0.0.2
# homeassistant.components.iskra
-pyiskra==0.1.21
+pyiskra==0.1.27
# homeassistant.components.iss
pyiss==1.0.1
@@ -2102,7 +2138,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
-pylamarzocco==2.0.11
+pylamarzocco==2.1.1
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2120,10 +2156,10 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
-pylitterbot==2024.2.3
+pylitterbot==2024.2.4
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.24.0
+pylutron-caseta==0.25.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -2144,7 +2180,7 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
-pymiele==0.5.4
+pymiele==0.5.5
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2153,7 +2189,7 @@ pymitv==1.4.3
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.11.1
+pymodbus==3.11.2
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -2165,7 +2201,7 @@ pymsteams==0.1.12
pymysensors==0.26.0
# homeassistant.components.iron_os
-pynecil==4.1.1
+pynecil==4.2.0
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2180,7 +2216,7 @@ pynina==0.3.6
pynobo==1.8.1
# homeassistant.components.nordpool
-pynordpool==0.3.0
+pynordpool==0.3.1
# homeassistant.components.nuki
pynuki==1.6.3
@@ -2224,7 +2260,7 @@ pyotgw==2.2.2
# homeassistant.auth.mfa_modules.notify
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.otp
-pyotp==2.8.0
+pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.17.2
@@ -2242,7 +2278,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.8.10
+pypck==0.8.12
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2256,6 +2292,9 @@ pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
+# homeassistant.components.portainer
+pyportainer==1.0.3
+
# homeassistant.components.probe_plus
pyprobeplus==1.0.1
@@ -2284,7 +2323,7 @@ pyrail==0.4.1
pyrainbird==6.0.1
# homeassistant.components.playstation_network
-pyrate-limiter==3.7.0
+pyrate-limiter==3.9.0
# homeassistant.components.recswitch
pyrecswitch==1.0.2
@@ -2311,7 +2350,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
-pyschlage==2025.7.3
+pyschlage==2025.9.0
# homeassistant.components.sensibo
pysensibo==1.2.1
@@ -2321,6 +2360,7 @@ pyserial-asyncio-fast==0.16
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
+# homeassistant.components.route_b_smart_meter
# homeassistant.components.usb
# homeassistant.components.zwave_js
pyserial==3.5
@@ -2350,13 +2390,13 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
-pysmartthings==3.2.9
+pysmartthings==3.3.0
# homeassistant.components.smarty
-pysmarty2==0.10.2
+pysmarty2==0.10.3
# homeassistant.components.smhi
-pysmhi==1.0.2
+pysmhi==1.1.0
# homeassistant.components.edl21
pysml==0.1.5
@@ -2443,7 +2483,7 @@ python-google-drive-api==0.1.0
python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
-python-homewizard-energy==9.2.0
+python-homewizard-energy==9.3.0
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2467,7 +2507,7 @@ python-linkplay==0.2.12
python-matter-server==8.1.0
# homeassistant.components.melcloud
-python-melcloud==0.1.0
+python-melcloud==0.1.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2497,6 +2537,9 @@ python-overseerr==0.7.1
# homeassistant.components.picnic
python-picnic-api2==1.3.1
+# homeassistant.components.pooldose
+python-pooldose==0.5.0
+
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2504,7 +2547,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==2.18.2
+python-roborock==2.50.2
# homeassistant.components.smarttub
python-smarttub==0.0.44
@@ -2543,7 +2586,7 @@ pytomorrowio==0.3.6
pytouchline_extended==0.4.5
# homeassistant.components.touchline_sl
-pytouchlinesl==0.4.0
+pytouchlinesl==0.5.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -2574,7 +2617,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
-pyvesync==2.1.18
+pyvesync==3.1.0
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2628,7 +2671,7 @@ qbittorrent-api==2024.9.67
qbusmqttapi==1.4.2
# homeassistant.components.qingping
-qingping-ble==0.10.0
+qingping-ble==1.0.1
# homeassistant.components.qnap
qnapstats==0.4.0
@@ -2658,13 +2701,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.4.0
+renault-api==0.4.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.14.6
+reolink-aio==0.16.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2764,7 +2807,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
-sharkiq==1.1.1
+sharkiq==1.4.0
# homeassistant.components.aquostv
sharp_aquos_rc==0.3.2
@@ -2803,13 +2846,13 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.11
+soco==0.30.12
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
# homeassistant.components.solarlog
-solarlog_cli==0.5.0
+solarlog_cli==0.6.0
# homeassistant.components.solax
solax==3.2.3
@@ -2871,13 +2914,13 @@ surepy==0.9.0
swisshydrodata==0.1.0
# homeassistant.components.switchbot_cloud
-switchbot-api==2.7.0
+switchbot-api==2.8.0
# homeassistant.components.synology_srm
synology-srm==0.2.0
# homeassistant.components.system_bridge
-systembridgeconnector==4.1.10
+systembridgeconnector==5.1.0
# homeassistant.components.tailscale
tailscale==0.6.2
@@ -2936,7 +2979,7 @@ thermopro-ble==0.13.1
thingspeak==1.0.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.7
+thinqconnect==1.0.8
# homeassistant.components.tikteck
tikteck==0.4
@@ -2975,7 +3018,7 @@ tplink-omada-client==1.4.4
transmission-rpc==7.0.3
# homeassistant.components.triggercmd
-triggercmd==0.0.27
+triggercmd==0.0.36
# homeassistant.components.twinkly
ttls==1.8.3
@@ -2984,7 +3027,7 @@ ttls==1.8.3
ttn_client==1.2.0
# homeassistant.components.tuya
-tuya-device-sharing-sdk==0.2.1
+tuya-device-sharing-sdk==0.2.4
# homeassistant.components.twentemilieu
twentemilieu==2.2.1
@@ -3017,13 +3060,13 @@ unifi_ap==0.0.2
unifiled==0.11
# homeassistant.components.homeassistant_hardware
-universal-silabs-flasher==0.0.31
+universal-silabs-flasher==0.0.35
# homeassistant.components.upb
upb-lib==0.6.1
# homeassistant.components.upcloud
-upcloud-api==2.6.0
+upcloud-api==2.9.0
# homeassistant.components.huawei_lte
# homeassistant.components.syncthru
@@ -3051,6 +3094,9 @@ velbus-aio==2025.8.0
# homeassistant.components.venstar
venstarcolortouch==0.21
+# homeassistant.components.victron_remote_monitoring
+victron-vrm==0.1.7
+
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
@@ -3061,10 +3107,7 @@ voip-utils==0.3.4
volkszaehler==0.4.0
# homeassistant.components.volvo
-volvocarsapi==0.4.1
-
-# homeassistant.components.volvooncall
-volvooncall==0.10.3
+volvocarsapi==0.4.2
# homeassistant.components.verisure
vsure==2.6.7
@@ -3072,12 +3115,6 @@ vsure==2.6.7
# homeassistant.components.vasttrafik
vtjp==0.2.1
-# homeassistant.components.vulcan
-vulcan-api==2.4.2
-
-# homeassistant.components.vultr
-vultr==0.1.2
-
# homeassistant.components.samsungtv
# homeassistant.components.wake_on_lan
wakeonlan==3.1.0
@@ -3089,7 +3126,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
-waterfurnace==1.1.0
+waterfurnace==1.2.0
# homeassistant.components.watergate
watergate-local-api==2024.4.1
@@ -3110,7 +3147,7 @@ webmin-xmlrpc==0.0.2
weheat==2025.6.10
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.21.1
+whirlpool-sixth-sense==0.21.3
# homeassistant.components.whois
whois==0.9.27
@@ -3140,7 +3177,7 @@ xbox-webapi==2.1.0
xiaomi-ble==1.2.0
# homeassistant.components.knx
-xknx==3.8.0
+xknx==3.9.0
# homeassistant.components.knx
xknxproject==3.8.2
@@ -3165,7 +3202,7 @@ yalexs-ble==3.1.2
# homeassistant.components.august
# homeassistant.components.yale
-yalexs==8.12.0
+yalexs==9.2.0
# homeassistant.components.yeelight
yeelight==0.7.16
@@ -3183,25 +3220,25 @@ youless-api==2.2.0
youtubeaio==2.0.0
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.08.11
+yt-dlp[default]==2025.09.26
# homeassistant.components.zabbix
-zabbix-utils==2.0.2
+zabbix-utils==2.0.3
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zimi
-zcc-helper==3.6
+zcc-helper==3.7
# homeassistant.components.zeroconf
-zeroconf==0.147.0
+zeroconf==0.148.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.69
+zha==0.0.73
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
diff --git a/requirements_test.txt b/requirements_test.txt
index 9df62168b19..78750341109 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -8,20 +8,20 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==3.3.11
-coverage==7.10.0
+coverage==7.10.6
freezegun==1.5.2
go2rtc-client==0.2.1
license-expression==30.4.3
mock-open==1.4.0
-mypy-dev==1.18.0a4
+mypy-dev==1.19.0a2
pre-commit==4.2.0
-pydantic==2.11.7
+pydantic==2.11.9
pylint==3.3.8
pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1
-pytest-asyncio==1.1.0
+pytest-asyncio==1.2.0
pytest-aiohttp==1.1.0
-pytest-cov==6.2.1
+pytest-cov==7.0.0
pytest-freezer==0.4.9
pytest-github-actions-annotate-failures==0.3.0
pytest-socket==0.7.0
@@ -30,24 +30,24 @@ pytest-timeout==2.4.0
pytest-unordered==0.7.0
pytest-picked==0.5.1
pytest-xdist==3.8.0
-pytest==8.4.1
+pytest==8.4.2
requests-mock==1.12.1
respx==0.22.0
syrupy==4.9.1
tqdm==4.67.1
-types-aiofiles==24.1.0.20250809
+types-aiofiles==24.1.0.20250822
types-atomicwrites==1.4.5.1
types-croniter==6.0.0.20250809
types-caldav==1.3.0.20250516
types-chardet==0.1.5
types-decorator==5.2.0.20250324
-types-pexpect==4.9.0.20250809
-types-protobuf==6.30.2.20250809
-types-psutil==7.0.0.20250801
-types-pyserial==3.5.0.20250809
-types-python-dateutil==2.9.0.20250809
+types-pexpect==4.9.0.20250916
+types-protobuf==6.30.2.20250914
+types-psutil==7.0.0.20251001
+types-pyserial==3.5.0.20251001
+types-python-dateutil==2.9.0.20250822
types-python-slugify==8.0.2.20240310
types-pytz==2025.2.0.20250809
-types-PyYAML==6.0.12.20250809
-types-requests==2.32.4.20250809
+types-PyYAML==6.0.12.20250915
+types-requests==2.32.4.20250913
types-xmltodict==0.13.0.3
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index c4d5182edf9..af38568cdfb 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -16,10 +16,10 @@ Adax-local==0.1.5
DoorBirdPy==3.0.8
# homeassistant.components.homekit
-HAP-python==4.9.2
+HAP-python==5.0.0
# homeassistant.components.tasmota
-HATasmota==0.10.0
+HATasmota==0.10.1
# homeassistant.components.mastodon
Mastodon.py==2.1.2
@@ -71,7 +71,7 @@ PyMicroBot==0.0.23
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
-PyNaCl==1.5.0
+PyNaCl==1.6.0
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.homekit
@@ -81,7 +81,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.69.0
+PySwitchbot==0.71.0
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -94,7 +94,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
-PyViCare==2.50.0
+PyViCare==2.52.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0
WSDiscovery==2.1.2
# homeassistant.components.accuweather
-accuweather==4.2.0
+accuweather==4.2.2
# homeassistant.components.adax
adax==0.4.0
@@ -161,26 +161,26 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.14
+aioacaia==0.1.17
# homeassistant.components.airq
aioairq==0.4.6
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.7.1
+aioairzone-cloud==0.7.2
# homeassistant.components.airzone
-aioairzone==1.0.0
+aioairzone==1.0.1
# homeassistant.components.alexa_devices
-aioamazondevices==5.0.0
+aioamazondevices==6.2.9
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
aioambient==2024.08.0
# homeassistant.components.apcupsd
-aioapcaccess==0.4.2
+aioapcaccess==1.0.0
# homeassistant.components.aquacell
aioaquacell==0.2.0
@@ -192,7 +192,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2.1.2
+aioautomower==2.2.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -226,16 +226,16 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2025.3.1
+aioecowitt==2025.9.2
# homeassistant.components.co2signal
-aioelectricitymaps==0.4.0
+aioelectricitymaps==1.1.1
# homeassistant.components.emonitor
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==39.0.0
+aioesphomeapi==41.12.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -247,22 +247,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.5.2
+aioharmony==0.5.3
# homeassistant.components.hassio
-aiohasupervisor==0.3.2b0
+aiohasupervisor==0.3.3
# homeassistant.components.home_connect
-aiohomeconnect==0.18.1
+aiohomeconnect==0.20.0
# homeassistant.components.homekit_controller
-aiohomekit==3.2.15
+aiohomekit==3.2.20
# homeassistant.components.mcp_server
aiohttp_sse==2.2.0
# homeassistant.components.hue
-aiohue==4.7.4
+aiohue==4.8.0
# homeassistant.components.imap
aioimaplib==2.0.1
@@ -280,7 +280,7 @@ aiokem==1.0.1
aiolifx-effects==0.3.2
# homeassistant.components.lifx
-aiolifx-themes==0.6.4
+aiolifx-themes==1.0.2
# homeassistant.components.lifx
aiolifx==1.2.1
@@ -292,7 +292,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
-aiomealie==0.10.1
+aiomealie==1.0.0
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -307,7 +307,7 @@ aionanoleaf==0.2.1
aionotion==2024.03.0
# homeassistant.components.ntfy
-aiontfy==0.5.4
+aiontfy==0.6.1
# homeassistant.components.nut
aionut==4.3.4
@@ -328,10 +328,10 @@ aiopegelonline==0.1.1
aiopulse==0.4.6
# homeassistant.components.purpleair
-aiopurpleair==2023.12.0
+aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview
-aiopvapi==3.1.1
+aiopvapi==3.2.1
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2
@@ -351,13 +351,13 @@ aioraven==0.7.1
aiorecollect==2023.09.0
# homeassistant.components.ridwell
-aioridwell==2024.01.0
+aioridwell==2025.09.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.8.1
+aiorussound==4.8.2
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -366,7 +366,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==13.8.0
+aioshelly==13.11.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -399,7 +399,7 @@ aiotedee==0.2.25
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==86
+aiounifi==87
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -408,7 +408,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
-aiovodafone==0.10.0
+aiovodafone==1.2.1
# homeassistant.components.waqi
aiowaqi==3.1.0
@@ -435,10 +435,10 @@ airgradient==0.9.2
airly==1.1.0
# homeassistant.components.airos
-airos==0.4.3
+airos==0.5.5
# homeassistant.components.airthings_ble
-airthings-ble==0.9.2
+airthings-ble==1.1.1
# homeassistant.components.airthings
airthings-cloud==0.2.0
@@ -468,10 +468,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.62.0
+anthropic==0.69.0
# homeassistant.components.mcp_server
-anyio==4.9.0
+anyio==4.10.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -492,7 +492,7 @@ aranet4==2.5.1
arcam-fmj==1.8.2
# homeassistant.components.asuswrt
-asusrouter==1.20.0
+asusrouter==1.21.0
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
@@ -508,6 +508,9 @@ asyncarve==0.1.1
# homeassistant.components.sleepiq
asyncsleepiq==1.6.0
+# homeassistant.components.sftp_storage
+asyncssh==2.21.0
+
# homeassistant.components.aurora
auroranoaa==0.0.5
@@ -555,14 +558,13 @@ base36==0.1.1
beautifulsoup4==4.13.3
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.17.2
+bimmer-connected[china]==0.17.3
-# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==3.1.0
+bleak-esphome==3.4.0
# homeassistant.components.bluetooth
-bleak-retry-connector==4.3.0
+bleak-retry-connector==4.4.3
# homeassistant.components.bluetooth
bleak==1.0.1
@@ -583,16 +585,16 @@ bluemaestro-ble==0.4.1
# bluepy==1.3.0
# homeassistant.components.bluetooth
-bluetooth-adapters==2.0.0
+bluetooth-adapters==2.1.0
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.5.2
+bluetooth-auto-recovery==1.5.3
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.28.2
+bluetooth-data-tools==1.28.3
# homeassistant.components.bond
bond-async==0.2.1
@@ -613,7 +615,7 @@ bring-api==1.1.0
broadlink==0.19.0
# homeassistant.components.brother
-brother==5.0.1
+brother==5.1.0
# homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5
@@ -622,13 +624,13 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
-bthome-ble==3.13.1
+bthome-ble==3.14.2
# homeassistant.components.buienradar
buienradar==1.0.6
# homeassistant.components.dhcp
-cached-ipaddress==0.10.0
+cached-ipaddress==1.0.1
# homeassistant.components.caldav
caldav==1.6.0
@@ -642,6 +644,9 @@ colorlog==6.9.0
# homeassistant.components.color_extractor
colorthief==0.2.1
+# homeassistant.components.compit
+compit-inext-api==0.3.1
+
# homeassistant.components.xiaomi_miio
construct==2.10.68
@@ -668,16 +673,16 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
-dbus-fast==2.44.3
+dbus-fast==2.44.5
# homeassistant.components.debugpy
-debugpy==1.8.14
+debugpy==1.8.16
# homeassistant.components.decora
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==13.6.0
+deebot-client==15.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -738,6 +743,9 @@ easyenergy==2.1.2
# homeassistant.components.eheimdigital
eheimdigital==1.3.0
+# homeassistant.components.ekeybionyx
+ekey-bionyxpy==1.0.0
+
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@@ -784,7 +792,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
-eq3btsmart==2.1.0
+eq3btsmart==2.3.0
# homeassistant.components.esphome
esphome-dashboard-api==1.3.0
@@ -812,7 +820,7 @@ faadelays==2023.9.1
fastdotcom==0.0.3
# homeassistant.components.feedreader
-feedparser==6.0.11
+feedparser==6.0.12
# homeassistant.components.file
file-read-backwards==2.0.0
@@ -827,7 +835,7 @@ fitbit==0.3.1
fivem-api==0.1.2
# homeassistant.components.fjaraskupan
-fjaraskupan==2.3.2
+fjaraskupan==2.3.3
# homeassistant.components.flexit_bacnet
flexit_bacnet==2.2.3
@@ -843,7 +851,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.5.0
+fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -856,7 +864,7 @@ freebox-api==1.2.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
-fritzconnection[qr]==1.14.0
+fritzconnection[qr]==1.15.0
# homeassistant.components.fyta
fyta_cli==0.7.2
@@ -865,6 +873,7 @@ fyta_cli==0.7.2
gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
+# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
@@ -873,6 +882,9 @@ gassist-text==0.0.14
# homeassistant.components.google
gcal-sync==8.0.0
+# homeassistant.components.aladdin_connect
+genie-partner-sdk==1.0.11
+
# homeassistant.components.geniushub
geniushub-client==0.7.1
@@ -927,7 +939,7 @@ google-cloud-speech==2.31.1
google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-genai==1.29.0
+google-genai==1.38.0
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -949,7 +961,7 @@ gotailwind==0.3.0
govee-ble==0.44.0
# homeassistant.components.govee_light_local
-govee-local-api==2.1.0
+govee-local-api==2.2.0
# homeassistant.components.gpsd
gps3==0.33.3
@@ -976,7 +988,7 @@ gstreamer-player==1.1.2
guppy3==3.1.5
# homeassistant.components.iaqualink
-h2==4.2.0
+h2==4.3.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.2.2
@@ -985,23 +997,23 @@ ha-ffmpeg==3.2.2
ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
-ha-philipsjs==3.2.2
+ha-philipsjs==3.2.4
# homeassistant.components.homeassistant_hardware
ha-silabs-firmware-client==0.2.0
# homeassistant.components.habitica
-habiticalib==0.4.3
+habiticalib==0.4.5
# homeassistant.components.bluetooth
-habluetooth==5.1.0
+habluetooth==5.7.0
# homeassistant.components.cloud
-hass-nabucasa==1.0.0
+hass-nabucasa==1.2.0
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
-hassil==3.1.0
+hassil==3.2.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.2
@@ -1023,16 +1035,16 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.79
+holidays==0.81
# homeassistant.components.frontend
-home-assistant-frontend==20250811.1
+home-assistant-frontend==20251001.0
# homeassistant.components.conversation
-home-assistant-intents==2025.7.30
+home-assistant-intents==2025.10.1
# homeassistant.components.homematicip_cloud
-homematicip==2.2.0
+homematicip==2.3.0
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -1047,7 +1059,7 @@ huum==0.8.1
hyperion-py==0.7.6
# homeassistant.components.iaqualink
-iaqualink==0.5.3
+iaqualink==0.6.0
# homeassistant.components.ibeacon
ibeacon-ble==1.2.0
@@ -1074,10 +1086,10 @@ ifaddr==0.2.0
igloohome-api==0.1.1
# homeassistant.components.imeon_inverter
-imeon_inverter_api==0.3.14
+imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
-imgw_pib==1.5.4
+imgw_pib==1.5.6
# homeassistant.components.incomfort
incomfort-client==0.6.9
@@ -1103,8 +1115,11 @@ iometer==0.1.0
# homeassistant.components.iotty
iottycloud==0.3.0
+# homeassistant.components.irm_kmi
+irm-kmi-api==1.1.0
+
# homeassistant.components.isal
-isal==1.7.1
+isal==1.8.0
# homeassistant.components.gogogate2
ismartgate==5.0.2
@@ -1167,6 +1182,9 @@ letpot==0.6.2
# homeassistant.components.foscam
libpyfoscamcgi==0.0.7
+# homeassistant.components.libre_hardware_monitor
+librehardwaremonitor-api==1.4.0
+
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1185,6 +1203,9 @@ loqedAPI==2.1.10
# homeassistant.components.luftdaten
luftdaten==0.7.4
+# homeassistant.components.lunatone
+lunatone-rest-api-client==0.4.8
+
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1202,7 +1223,7 @@ mbddns==0.1.2
# homeassistant.components.mcp
# homeassistant.components.mcp_server
-mcp==1.5.0
+mcp==1.14.1
# homeassistant.components.minecraft_server
mcstatus==12.0.1
@@ -1216,6 +1237,9 @@ medcom-ble==0.1.1
# homeassistant.components.melnor
melnor-bluetooth==0.0.25
+# homeassistant.components.meteo_lt
+meteo-lt-pkg==0.2.4
+
# homeassistant.components.meteo_france
meteofrance-api==1.4.0
@@ -1232,7 +1256,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
-millheater==0.12.5
+millheater==0.14.0
# homeassistant.components.minio
minio==7.1.12
@@ -1243,6 +1267,9 @@ moat-ble==0.1.1
# homeassistant.components.moehlenhoff_alpha2
moehlenhoff-alpha2==1.4.0
+# homeassistant.components.route_b_smart_meter
+momonga==0.1.5
+
# homeassistant.components.monzo
monzopy==1.5.1
@@ -1283,7 +1310,7 @@ myuplink==0.7.0
ndms2-client==0.1.2
# homeassistant.components.ness_alarm
-nessclient==1.2.0
+nessclient==1.3.1
# homeassistant.components.nmap_tracker
netmap==0.7.0.2
@@ -1292,7 +1319,7 @@ netmap==0.7.0.2
nettigo-air-monitor==5.0.0
# homeassistant.components.nexia
-nexia==2.10.0
+nexia==2.11.1
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1307,7 +1334,7 @@ nextdns==4.1.0
nhc==0.4.12
# homeassistant.components.nibe_heatpump
-nibe==2.17.0
+nibe==2.19.0
# homeassistant.components.nice_go
nice-go==1.0.1
@@ -1318,6 +1345,9 @@ notifications-android-tv==0.1.5
# homeassistant.components.notify_events
notify-events==1.0.4
+# homeassistant.components.nederlandse_spoorwegen
+nsapi==3.1.2
+
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.1.0
@@ -1347,7 +1377,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
-ohme==1.5.1
+ohme==1.5.2
# homeassistant.components.ollama
ollama==0.5.1
@@ -1384,7 +1414,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.15.2
+opower==0.15.6
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1393,10 +1423,10 @@ oralb-ble==0.17.6
ourgroceries==1.5.4
# homeassistant.components.ovo_energy
-ovoenergy==2.0.1
+ovoenergy==3.0.2
# homeassistant.components.p1_monitor
-p1monitor==3.1.0
+p1monitor==3.2.0
# homeassistant.components.mqtt
paho-mqtt==2.1.0
@@ -1436,9 +1466,6 @@ plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.7.8
-# homeassistant.components.plum_lightpad
-plumlightpad==0.0.11
-
# homeassistant.components.poolsense
poolsense==0.0.8
@@ -1454,6 +1481,9 @@ prayer-times-calculator-offline==1.0.3
# homeassistant.components.prometheus
prometheus-client==0.21.0
+# homeassistant.components.prowl
+prowlpy==1.0.2
+
# homeassistant.components.hardware
# homeassistant.components.recorder
# homeassistant.components.systemmonitor
@@ -1472,14 +1502,11 @@ pushover_complete==1.2.0
pvo==2.2.1
# homeassistant.components.aosmith
-py-aosmith==1.0.12
+py-aosmith==1.0.14
# homeassistant.components.canary
py-canary==0.5.4
-# homeassistant.components.ccm15
-py-ccm15==0.0.9
-
# homeassistant.components.cpuspeed
py-cpuinfo==9.0.0
@@ -1523,17 +1550,20 @@ pyDuotecno==2024.10.1
pyElectra==1.2.4
# homeassistant.components.homee
-pyHomee==1.2.10
+pyHomee==1.3.8
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
# homeassistant.components.tibber
-pyTibber==0.31.6
+pyTibber==0.32.2
# homeassistant.components.dlink
pyW215==0.8.0
+# homeassistant.components.ccm15
+py_ccm15==0.1.2
+
# homeassistant.components.hisense_aehw4a1
pyaehw4a1==0.3.9
@@ -1569,7 +1599,7 @@ pybalboa==1.1.3
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==2.0.4
+pyblu==2.0.5
# homeassistant.components.neato
pybotvac==0.0.28
@@ -1598,8 +1628,11 @@ pycsspeechtts==1.0.8
# homeassistant.components.cups
# pycups==2.0.4
+# homeassistant.components.cync
+pycync==0.4.1
+
# homeassistant.components.daikin
-pydaikin==2.16.0
+pydaikin==2.17.1
# homeassistant.components.deako
pydeako==0.6.0
@@ -1614,11 +1647,14 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
-pydrawise==2025.7.0
+pydrawise==2025.9.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==3.0.0
+# homeassistant.components.droplet
+pydroplet==2.3.3
+
# homeassistant.components.ecoforest
pyecoforest==0.4.0
@@ -1626,7 +1662,7 @@ pyecoforest==0.4.0
pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
-pyecotrend-ista==3.3.1
+pyecotrend-ista==3.4.0
# homeassistant.components.efergy
pyefergy==22.5.0
@@ -1636,10 +1672,10 @@ pyegps==0.2.5
# homeassistant.components.emoncms
# homeassistant.components.emoncms_history
-pyemoncms==0.1.2
+pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
-pyenphase==2.3.0
+pyenphase==2.4.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1656,6 +1692,9 @@ pyfibaro==0.8.3
# homeassistant.components.fido
pyfido==2.1.2
+# homeassistant.components.firefly_iii
+pyfirefly==0.1.6
+
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -1696,7 +1735,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
-pyicloud==1.0.0
+pyicloud==2.0.3
# homeassistant.components.insteon
pyinsteon==1.6.3
@@ -1711,7 +1750,7 @@ pyipp==0.17.0
pyiqvia==2022.04.0
# homeassistant.components.iskra
-pyiskra==0.1.21
+pyiskra==0.1.27
# homeassistant.components.iss
pyiss==1.0.1
@@ -1747,7 +1786,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
-pylamarzocco==2.0.11
+pylamarzocco==2.1.1
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1765,10 +1804,10 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
-pylitterbot==2024.2.3
+pylitterbot==2024.2.4
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.24.0
+pylutron-caseta==0.25.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -1786,13 +1825,13 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
-pymiele==0.5.4
+pymiele==0.5.5
# homeassistant.components.mochad
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==3.11.1
+pymodbus==3.11.2
# homeassistant.components.monoprice
pymonoprice==0.4
@@ -1801,7 +1840,7 @@ pymonoprice==0.4
pymysensors==0.26.0
# homeassistant.components.iron_os
-pynecil==4.1.1
+pynecil==4.2.0
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -1813,7 +1852,7 @@ pynina==0.3.6
pynobo==1.8.1
# homeassistant.components.nordpool
-pynordpool==0.3.0
+pynordpool==0.3.1
# homeassistant.components.nuki
pynuki==1.6.3
@@ -1851,7 +1890,7 @@ pyotgw==2.2.2
# homeassistant.auth.mfa_modules.notify
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.otp
-pyotp==2.8.0
+pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.17.2
@@ -1866,7 +1905,7 @@ pypalazzetti==0.1.19
pypaperless==4.1.1
# homeassistant.components.lcn
-pypck==0.8.10
+pypck==0.8.12
# homeassistant.components.pglab
pypglab==0.0.5
@@ -1880,6 +1919,9 @@ pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
+# homeassistant.components.portainer
+pyportainer==1.0.3
+
# homeassistant.components.probe_plus
pyprobeplus==1.0.1
@@ -1905,7 +1947,7 @@ pyrail==0.4.1
pyrainbird==6.0.1
# homeassistant.components.playstation_network
-pyrate-limiter==3.7.0
+pyrate-limiter==3.9.0
# homeassistant.components.risco
pyrisco==0.6.7
@@ -1923,13 +1965,14 @@ pyrympro==0.0.9
pysabnzbd==1.1.1
# homeassistant.components.schlage
-pyschlage==2025.7.3
+pyschlage==2025.9.0
# homeassistant.components.sensibo
pysensibo==1.2.1
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
+# homeassistant.components.route_b_smart_meter
# homeassistant.components.usb
# homeassistant.components.zwave_js
pyserial==3.5
@@ -1953,13 +1996,13 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
-pysmartthings==3.2.9
+pysmartthings==3.3.0
# homeassistant.components.smarty
-pysmarty2==0.10.2
+pysmarty2==0.10.3
# homeassistant.components.smhi
-pysmhi==1.0.2
+pysmhi==1.1.0
# homeassistant.components.edl21
pysml==0.1.5
@@ -2022,7 +2065,7 @@ python-google-drive-api==0.1.0
python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
-python-homewizard-energy==9.2.0
+python-homewizard-energy==9.3.0
# homeassistant.components.izone
python-izone==1.2.9
@@ -2040,7 +2083,7 @@ python-linkplay==0.2.12
python-matter-server==8.1.0
# homeassistant.components.melcloud
-python-melcloud==0.1.0
+python-melcloud==0.1.2
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2070,11 +2113,14 @@ python-overseerr==0.7.1
# homeassistant.components.picnic
python-picnic-api2==1.3.1
+# homeassistant.components.pooldose
+python-pooldose==0.5.0
+
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
# homeassistant.components.roborock
-python-roborock==2.18.2
+python-roborock==2.50.2
# homeassistant.components.smarttub
python-smarttub==0.0.44
@@ -2104,7 +2150,7 @@ pytile==2024.12.0
pytomorrowio==0.3.6
# homeassistant.components.touchline_sl
-pytouchlinesl==0.4.0
+pytouchlinesl==0.5.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -2132,7 +2178,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.16
# homeassistant.components.vesync
-pyvesync==2.1.18
+pyvesync==3.1.0
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2180,7 +2226,7 @@ qbittorrent-api==2024.9.67
qbusmqttapi==1.4.2
# homeassistant.components.qingping
-qingping-ble==0.10.0
+qingping-ble==1.0.1
# homeassistant.components.qnap
qnapstats==0.4.0
@@ -2204,13 +2250,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.4.0
+renault-api==0.4.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.14.6
+reolink-aio==0.16.1
# homeassistant.components.rflink
rflink==0.0.67
@@ -2251,6 +2297,9 @@ samsungtvws[async,encrypted]==2.7.2
# homeassistant.components.sanix
sanix==1.0.6
+# homeassistant.components.satel_integra
+satel-integra==0.3.7
+
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2286,7 +2335,7 @@ sentry-sdk==1.45.1
sfrbox-api==0.0.12
# homeassistant.components.sharkiq
-sharkiq==1.1.1
+sharkiq==1.4.0
# homeassistant.components.simplefin
simplefin4py==0.0.18
@@ -2313,10 +2362,10 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.11
+soco==0.30.12
# homeassistant.components.solarlog
-solarlog_cli==0.5.0
+solarlog_cli==0.6.0
# homeassistant.components.solax
solax==3.2.3
@@ -2372,10 +2421,10 @@ subarulink==0.7.13
surepy==0.9.0
# homeassistant.components.switchbot_cloud
-switchbot-api==2.7.0
+switchbot-api==2.8.0
# homeassistant.components.system_bridge
-systembridgeconnector==4.1.10
+systembridgeconnector==5.1.0
# homeassistant.components.tailscale
tailscale==0.6.2
@@ -2419,7 +2468,7 @@ thermobeacon-ble==0.10.0
thermopro-ble==0.13.1
# homeassistant.components.lg_thinq
-thinqconnect==1.0.7
+thinqconnect==1.0.8
# homeassistant.components.tilt_ble
tilt-ble==0.3.1
@@ -2449,7 +2498,7 @@ tplink-omada-client==1.4.4
transmission-rpc==7.0.3
# homeassistant.components.triggercmd
-triggercmd==0.0.27
+triggercmd==0.0.36
# homeassistant.components.twinkly
ttls==1.8.3
@@ -2458,7 +2507,7 @@ ttls==1.8.3
ttn_client==1.2.0
# homeassistant.components.tuya
-tuya-device-sharing-sdk==0.2.1
+tuya-device-sharing-sdk==0.2.4
# homeassistant.components.twentemilieu
twentemilieu==2.2.1
@@ -2485,13 +2534,13 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.homeassistant_hardware
-universal-silabs-flasher==0.0.31
+universal-silabs-flasher==0.0.35
# homeassistant.components.upb
upb-lib==0.6.1
# homeassistant.components.upcloud
-upcloud-api==2.6.0
+upcloud-api==2.9.0
# homeassistant.components.huawei_lte
# homeassistant.components.syncthru
@@ -2519,6 +2568,9 @@ velbus-aio==2025.8.0
# homeassistant.components.venstar
venstarcolortouch==0.21
+# homeassistant.components.victron_remote_monitoring
+victron-vrm==0.1.7
+
# homeassistant.components.vilfo
vilfo-api-client==0.5.0
@@ -2526,20 +2578,11 @@ vilfo-api-client==0.5.0
voip-utils==0.3.4
# homeassistant.components.volvo
-volvocarsapi==0.4.1
-
-# homeassistant.components.volvooncall
-volvooncall==0.10.3
+volvocarsapi==0.4.2
# homeassistant.components.verisure
vsure==2.6.7
-# homeassistant.components.vulcan
-vulcan-api==2.4.2
-
-# homeassistant.components.vultr
-vultr==0.1.2
-
# homeassistant.components.samsungtv
# homeassistant.components.wake_on_lan
wakeonlan==3.1.0
@@ -2566,7 +2609,7 @@ webmin-xmlrpc==0.0.2
weheat==2025.6.10
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.21.1
+whirlpool-sixth-sense==0.21.3
# homeassistant.components.whois
whois==0.9.27
@@ -2593,7 +2636,7 @@ xbox-webapi==2.1.0
xiaomi-ble==1.2.0
# homeassistant.components.knx
-xknx==3.8.0
+xknx==3.9.0
# homeassistant.components.knx
xknxproject==3.8.2
@@ -2615,7 +2658,7 @@ yalexs-ble==3.1.2
# homeassistant.components.august
# homeassistant.components.yale
-yalexs==8.12.0
+yalexs==9.2.0
# homeassistant.components.yeelight
yeelight==0.7.16
@@ -2630,22 +2673,22 @@ youless-api==2.2.0
youtubeaio==2.0.0
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.08.11
+yt-dlp[default]==2025.09.26
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zimi
-zcc-helper==3.6
+zcc-helper==3.7
# homeassistant.components.zeroconf
-zeroconf==0.147.0
+zeroconf==0.148.0
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.69
+zha==0.0.73
# homeassistant.components.zwave_js
zwave-js-server-python==0.67.1
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index b9c800be3ca..44689bd12fe 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.1
-ruff==0.12.1
+ruff==0.13.0
yamllint==1.37.1
diff --git a/script/bootstrap b/script/bootstrap
index 725cb856bbf..c903cd6c2a2 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -7,6 +7,12 @@ set -e
cd "$(realpath "$(dirname "$0")/..")"
echo "Installing development dependencies..."
-uv pip install wheel --constraint homeassistant/package_constraints.txt --upgrade
-uv pip install colorlog $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade
-uv pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade
+uv pip install \
+ -e . \
+ -r requirements_test.txt \
+ colorlog \
+ --constraint homeassistant/package_constraints.txt \
+ --upgrade \
+ --config-settings editable_mode=compat
+
+python3 -m script.translations develop --all
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 9f65409b9be..bdd8ed2cda1 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -113,9 +113,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.72.1
-grpcio-status==1.72.1
-grpcio-reflection==1.72.1
+grpcio==1.75.1
+grpcio-status==1.75.1
+grpcio-reflection==1.75.1
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -135,7 +135,7 @@ uuid==1000000000.0.0
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# requirements so we can directly link HA versions to these library versions.
-anyio==4.9.0
+anyio==4.10.0
h11==0.16.0
httpcore==1.0.9
@@ -145,7 +145,7 @@ hyperframe>=5.2.0
# Ensure we run compatible with musllinux build env
numpy==2.3.2
-pandas==2.3.0
+pandas==2.3.3
# Constrain multidict to avoid typing issues
# https://github.com/home-assistant/core/pull/67046
@@ -155,7 +155,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.11.7
+pydantic==2.11.9
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -245,10 +245,13 @@ num2words==0.5.14
# pymodbus does not follow SemVer, and it keeps getting
# downgraded or upgraded by custom components
# This ensures all use the same version
-pymodbus==3.11.1
+pymodbus==3.11.2
# Some packages don't support gql 4.0.0 yet
gql<4.0.0
+
+# Pin pytest-rerunfailures to prevent accidental breaks
+pytest-rerunfailures==16.0.1
"""
GENERATED_MESSAGE = (
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index dfa99c6bc75..43a6cc7678b 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -19,6 +19,7 @@ from . import (
dhcp,
docker,
icons,
+ integration_info,
json,
manifest,
metadata,
@@ -44,6 +45,7 @@ INTEGRATION_PLUGINS = [
dependencies,
dhcp,
icons,
+ integration_info,
json,
manifest,
mqtt,
diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py
index b9e9e7b82a4..ecb7ceca7f2 100644
--- a/script/hassfest/conditions.py
+++ b/script/hassfest/conditions.py
@@ -38,6 +38,7 @@ FIELD_SCHEMA = vol.Schema(
CONDITION_SCHEMA = vol.Any(
vol.Schema(
{
+ vol.Optional("target"): selector.TargetSelector.CONFIG_SCHEMA,
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
}
),
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 6dbb086f273..c127f5ae51e 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -27,12 +27,12 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \
stdlib-list==0.10.0 \
pipdeptree==2.26.1 \
tqdm==4.67.1 \
- ruff==0.12.1 \
+ ruff==0.13.0 \
PyTurboJPEG==1.8.0 \
go2rtc-client==0.2.1 \
ha-ffmpeg==3.2.2 \
- hassil==3.1.0 \
- home-assistant-intents==2025.7.30 \
+ hassil==3.2.0 \
+ home-assistant-intents==2025.10.1 \
mutagen==1.47.0 \
pymicro-vad==1.0.1 \
pyspeex-noise==1.0.2
diff --git a/script/hassfest/integration_info.py b/script/hassfest/integration_info.py
new file mode 100644
index 00000000000..8747e256be7
--- /dev/null
+++ b/script/hassfest/integration_info.py
@@ -0,0 +1,42 @@
+"""Write integration constants."""
+
+from __future__ import annotations
+
+from .model import Config, Integration
+from .serializer import format_python
+
+
+def validate(integrations: dict[str, Integration], config: Config) -> None:
+ """Validate integrations file."""
+
+ if config.specific_integrations:
+ return
+
+ int_type = "entity"
+
+ domains = [
+ integration.domain
+ for integration in integrations.values()
+ if integration.manifest.get("integration_type") == int_type
+ # Tag is type "entity" but has no entity platform
+ and integration.domain != "tag"
+ ]
+
+ code = [
+ "from enum import StrEnum",
+ "class EntityPlatforms(StrEnum):",
+ f' """Available {int_type} platforms."""',
+ ]
+ code.extend([f' {domain.upper()} = "{domain}"' for domain in sorted(domains)])
+
+ config.cache[f"integrations_{int_type}"] = format_python(
+ "\n".join(code), generator="script.hassfest"
+ )
+
+
+def generate(integrations: dict[str, Integration], config: Config) -> None:
+ """Generate integration file."""
+ int_type = "entity"
+ filename = "entity_platforms"
+ platform_path = config.root / f"homeassistant/generated/{filename}.py"
+ platform_path.write_text(config.cache[f"integrations_{int_type}"])
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 02c96930bf5..74aad78dc6a 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -79,6 +79,7 @@ NO_IOT_CLASS = [
"history",
"homeassistant",
"homeassistant_alerts",
+ "homeassistant_connect_zbt2",
"homeassistant_green",
"homeassistant_hardware",
"homeassistant_sky_connect",
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index 6501aee0733..7468afab890 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -139,7 +139,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"airvisual_pro",
"airzone",
"airzone_cloud",
- "aladdin_connect",
"alarmdecoder",
"alert",
"alexa",
@@ -153,7 +152,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"analytics",
"android_ip_webcam",
"androidtv",
- "androidtv_remote",
"anel_pwrctrl",
"anova",
"anthemav",
@@ -251,7 +249,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"cloud",
"cloudflare",
"cmus",
- "co2signal",
"coinbase",
"color_extractor",
"comed_hourly_pricing",
@@ -488,7 +485,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"hp_ilo",
"html5",
"http",
- "huawei_lte",
"hue",
"huisbaasje",
"hunterdouglas_powerview",
@@ -532,7 +528,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"itunes",
"izone",
"jellyfin",
- "jewish_calendar",
"joaoapps_join",
"juicenet",
"justnimbus",
@@ -691,7 +686,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"nexia",
"nextbus",
"nextcloud",
- "nextdns",
"nfandroidtv",
"nibe_heatpump",
"nice_go",
@@ -741,7 +735,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"openuv",
"openweathermap",
"opnsense",
- "opower",
"opple",
"oralb",
"oru",
@@ -860,7 +853,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"rympro",
"saj",
"sanix",
- "satel_integra",
"schlage",
"schluter",
"scrape",
@@ -1070,14 +1062,11 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"volkszaehler",
"volumio",
"volvooncall",
- "vulcan",
- "vultr",
"w800rf32",
"wake_on_lan",
"wallbox",
"waqi",
"waterfurnace",
- "watson_iot",
"watson_tts",
"watttime",
"waze_travel_time",
@@ -1181,7 +1170,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"analytics_insights",
"android_ip_webcam",
"androidtv",
- "androidtv_remote",
"anel_pwrctrl",
"anova",
"anthemav",
@@ -1732,7 +1720,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"nexia",
"nextbus",
"nextcloud",
- "nextdns",
"nyt_games",
"nfandroidtv",
"nibe_heatpump",
@@ -1785,7 +1772,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"openuv",
"openweathermap",
"opnsense",
- "opower",
"opple",
"oralb",
"oru",
@@ -1969,7 +1955,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"somfy_mylink",
"sonarr",
"songpal",
- "sonos",
"sony_projector",
"soundtouch",
"spaceapi",
@@ -2125,14 +2110,11 @@ INTEGRATIONS_WITHOUT_SCALE = [
"volkszaehler",
"volumio",
"volvooncall",
- "vulcan",
- "vultr",
"w800rf32",
"wake_on_lan",
"wallbox",
"waqi",
"waterfurnace",
- "watson_iot",
"watson_tts",
"watttime",
"waze_travel_time",
@@ -2254,6 +2236,7 @@ NO_QUALITY_SCALE = [
"tag",
"timer",
"trace",
+ "usage_prediction",
"webhook",
"websocket_api",
"zone",
@@ -2302,7 +2285,11 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
):
integration.add_error(
"quality_scale",
- "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.",
+ (
+ "New integrations marked as internal should be added to INTEGRATIONS_WITHOUT_SCALE in script/hassfest/quality_scale.py."
+ if integration.quality_scale == "internal"
+ else "Quality scale definition not found. New integrations are required to at least reach the Bronze tier."
+ ),
)
return
if declared_quality_scale is not None:
@@ -2347,7 +2334,11 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
):
integration.add_error(
"quality_scale",
- "New integrations are required to at least reach the Bronze tier.",
+ (
+ "New integrations marked as internal should be added to INTEGRATIONS_WITHOUT_SCALE in script/hassfest/quality_scale.py."
+ if integration.quality_scale == "internal"
+ else "New integrations are required to at least reach the Bronze tier."
+ ),
)
return
name = str(iqs_file)
diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py
index 00a81b93ef2..ddc3cb649e8 100644
--- a/script/hassfest/requirements.py
+++ b/script/hassfest/requirements.py
@@ -38,24 +38,29 @@ PACKAGE_CHECK_VERSION_RANGE = {
"pillow": "SemVer",
"pydantic": "SemVer",
"pyjwt": "SemVer",
+ "pymodbus": "Custom",
"pytz": "CalVer",
"requests": "SemVer",
"typing_extensions": "SemVer",
"urllib3": "SemVer",
"yarl": "SemVer",
+ "zeroconf": "SemVer",
}
PACKAGE_CHECK_PREPARE_UPDATE: dict[str, int] = {
# In the form dict("dependencyX": n+1)
# - dependencyX should be the name of the referenced dependency
# - current major version +1
# Pandas will only fully support Python 3.14 in v3.
+ # Zeroconf will switch to v1 soon, without any breaking changes.
"pandas": 3,
+ "zeroconf": 1,
}
PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# In the form dict("domain": {"package": {"dependency1", "dependency2"}})
# - domain is the integration domain
# - package is the package (can be transitive) referencing the dependency
# - dependencyX should be the name of the referenced dependency
+ "altruist": {"altruistclient": {"zeroconf"}},
"geocaching": {
# scipy version closely linked to numpy
# geocachingapi > reverse_geocode > scipy > numpy
@@ -65,6 +70,17 @@ PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# https://github.com/GClunies/noaa_coops/pull/69
"noaa-coops": {"pandas"}
},
+ "smarty": {
+ # Current has an upper bound on major >=3.11.0,<4.0.0
+ "pysmarty2": {"pymodbus"}
+ },
+ "stiebel_eltron": {
+ # Current has an upper bound on major >=3.10.0,<4.0.0
+ "pystiebeleltron": {"pymodbus"}
+ },
+ "xiaomi_miio": {
+ "python-miio": {"zeroconf"},
+ },
}
PACKAGE_REGEX = re.compile(
@@ -109,11 +125,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# pycmus > pbr > setuptools
"pbr": {"setuptools"}
},
- "concord232": {
- # https://bugs.launchpad.net/python-stevedore/+bug/2111694
- # concord232 > stevedore > pbr > setuptools
- "pbr": {"setuptools"}
- },
"delijn": {"pydelijn": {"async-timeout"}},
"devialet": {"async-upnp-client": {"async-timeout"}},
"dlna_dmr": {"async-upnp-client": {"async-timeout"}},
@@ -169,7 +180,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# universal-silabs-flasher > zigpy > pyserial-asyncio
"zigpy": {"pyserial-asyncio"},
},
- "homekit": {"hap-python": {"async-timeout"}},
"homewizard": {"python-homewizard-energy": {"async-timeout"}},
"imeon_inverter": {"imeon-inverter-api": {"async-timeout"}},
"influxdb": {
@@ -226,11 +236,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
},
"nibe_heatpump": {"nibe": {"async-timeout"}},
"norway_air": {"pymetno": {"async-timeout"}},
- "nx584": {
- # https://bugs.launchpad.net/python-stevedore/+bug/2111694
- # pynx584 > stevedore > pbr > setuptools
- "pbr": {"setuptools"}
- },
"opengarage": {"open-garage": {"async-timeout"}},
"openhome": {"async-upnp-client": {"async-timeout"}},
"opensensemap": {"opensensemap-api": {"async-timeout"}},
diff --git a/script/hassfest/services.py b/script/hassfest/services.py
index 84d3aaefa88..723a9ec9278 100644
--- a/script/hassfest/services.py
+++ b/script/hassfest/services.py
@@ -118,9 +118,19 @@ def _service_schema(targeted: bool, custom: bool) -> vol.Schema:
)
}
+ def raise_on_target_device_filter(value: dict[str, Any]) -> dict[str, Any]:
+ """Raise error if target has a device filter."""
+ if "device" in value:
+ raise vol.Invalid(
+ "Services do not support device filters on target, use a device "
+ "selector instead"
+ )
+ return value
+
if targeted:
- schema_dict[vol.Required("target")] = vol.Any(
- selector.TargetSelector.CONFIG_SCHEMA, None
+ schema_dict[vol.Required("target")] = vol.All(
+ selector.TargetSelector.CONFIG_SCHEMA,
+ raise_on_target_device_filter,
)
if custom:
@@ -160,6 +170,31 @@ VALIDATE_AS_CUSTOM_INTEGRATION = {
}
+def check_extraneous_translation_fields(
+ integration: Integration,
+ service_name: str,
+ strings: dict[str, Any],
+ service_schema: dict[str, Any],
+) -> None:
+ """Check for extraneous translation fields."""
+ if integration.core and "services" in strings:
+ section_fields = set()
+ for field in service_schema.get("fields", {}).values():
+ if "fields" in field:
+ # This is a section
+ section_fields.update(field["fields"].keys())
+ translation_fields = {
+ field
+ for field in strings["services"][service_name].get("fields", {})
+ if field not in service_schema.get("fields", {})
+ }
+ for field in translation_fields - section_fields:
+ integration.add_error(
+ "services",
+ f"Service {service_name} has a field {field} in the translations file that is not in the schema",
+ )
+
+
def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool:
"""Recursively go through a dir and it's children and find the regex."""
pattern = re.compile(search_pattern)
@@ -264,6 +299,10 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa
f"Service {service_name} has no description {error_msg_suffix}",
)
+ check_extraneous_translation_fields(
+ integration, service_name, strings, service_schema
+ )
+
# The same check is done for the description in each of the fields of the
# service schema.
for field_name, field_schema in service_schema.get("fields", {}).items():
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index e29967d6716..d476ea5da44 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -170,6 +170,9 @@ def gen_data_entry_schema(
vol.Optional("data"): {str: translation_value_validator},
vol.Optional("data_description"): {str: translation_value_validator},
vol.Optional("menu_options"): {str: translation_value_validator},
+ vol.Optional("menu_option_descriptions"): {
+ str: translation_value_validator
+ },
vol.Optional("submit"): translation_value_validator,
vol.Optional("sections"): {
str: {
diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py
index 7406e6f98ea..4eb376c435f 100644
--- a/script/hassfest/triggers.py
+++ b/script/hassfest/triggers.py
@@ -38,9 +38,7 @@ FIELD_SCHEMA = vol.Schema(
TRIGGER_SCHEMA = vol.Any(
vol.Schema(
{
- vol.Optional("target"): vol.Any(
- selector.TargetSelector.CONFIG_SCHEMA, None
- ),
+ vol.Optional("target"): selector.TargetSelector.CONFIG_SCHEMA,
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
}
),
diff --git a/script/licenses.py b/script/licenses.py
index ef62d4970dd..f33fb176860 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -212,7 +212,6 @@ TODO = {
"0.12.3"
), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved?
"caldav": AwesomeVersion("1.6.0"), # None -- GPL -- ['GNU General Public License (GPL)', 'Apache Software License'] # https://github.com/python-caldav/caldav
- "pyiskra": AwesomeVersion("0.1.21"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)']
"xbox-webapi": AwesomeVersion("2.1.0"), # None -- GPL -- ['MIT License']
}
# fmt: on
diff --git a/script/run-in-env.sh b/script/run-in-env.sh
index 1c7f76ccc1f..b64d311d8fe 100755
--- a/script/run-in-env.sh
+++ b/script/run-in-env.sh
@@ -19,7 +19,7 @@ else
# other common virtualenvs
my_path=$(git rev-parse --show-toplevel)
- for venv in venv .venv .; do
+ for venv in .venv venv .; do
if [ -f "${my_path}/${venv}/bin/activate" ]; then
. "${my_path}/${venv}/bin/activate"
break
diff --git a/script/setup b/script/setup
index 84ee074510a..00600b3c1ac 100755
--- a/script/setup
+++ b/script/setup
@@ -18,11 +18,11 @@ mkdir -p config
if [ ! -n "$VIRTUAL_ENV" ]; then
if [ -x "$(command -v uv)" ]; then
- uv venv venv
+ uv venv .venv
else
- python3 -m venv venv
+ python3 -m venv .venv
fi
- source venv/bin/activate
+ source .venv/bin/activate
fi
if ! [ -x "$(command -v uv)" ]; then
@@ -32,12 +32,10 @@ fi
script/bootstrap
pre-commit install
-uv pip install -e . --config-settings editable_mode=compat --constraint homeassistant/package_constraints.txt
-python3 -m script.translations develop --all
hass --script ensure_config -c config
-if ! grep -R "logger" config/configuration.yaml >> /dev/null;then
+if ! grep -R "logger" config/configuration.yaml >> /dev/null; then
echo "
logger:
default: info
diff --git a/tests/common.py b/tests/common.py
index e43e4bf5fee..419ba0ad466 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -934,6 +934,7 @@ class MockModule:
def mock_manifest(self):
"""Generate a mock manifest to represent this module."""
return {
+ "integration_type": "hub",
**loader.manifest_from_legacy_module(self.DOMAIN, self),
**(self._partial_manifest or {}),
}
diff --git a/tests/components/accuweather/conftest.py b/tests/components/accuweather/conftest.py
index 737fd3f84b6..abecc7cc198 100644
--- a/tests/components/accuweather/conftest.py
+++ b/tests/components/accuweather/conftest.py
@@ -14,7 +14,8 @@ from tests.common import load_json_array_fixture, load_json_object_fixture
def mock_accuweather_client() -> Generator[AsyncMock]:
"""Mock a AccuWeather client."""
current = load_json_object_fixture("current_conditions_data.json", DOMAIN)
- forecast = load_json_array_fixture("forecast_data.json", DOMAIN)
+ daily_forecast = load_json_array_fixture("daily_forecast_data.json", DOMAIN)
+ hourly_forecast = load_json_array_fixture("hourly_forecast_data.json", DOMAIN)
location = load_json_object_fixture("location_data.json", DOMAIN)
with (
@@ -29,7 +30,8 @@ def mock_accuweather_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.async_get_location.return_value = location
client.async_get_current_conditions.return_value = current
- client.async_get_daily_forecast.return_value = forecast
+ client.async_get_daily_forecast.return_value = daily_forecast
+ client.async_get_hourly_forecast.return_value = hourly_forecast
client.location_key = "0123456"
client.requests_remaining = 10
diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/daily_forecast_data.json
similarity index 100%
rename from tests/components/accuweather/fixtures/forecast_data.json
rename to tests/components/accuweather/fixtures/daily_forecast_data.json
diff --git a/tests/components/accuweather/fixtures/hourly_forecast_data.json b/tests/components/accuweather/fixtures/hourly_forecast_data.json
new file mode 100644
index 00000000000..43a04d533a1
--- /dev/null
+++ b/tests/components/accuweather/fixtures/hourly_forecast_data.json
@@ -0,0 +1,1334 @@
+[
+ {
+ "DateTime": "2025-09-12t16:00:00+02:00",
+ "EpochDateTime": 1757685600,
+ "WeatherIcon": 2,
+ "IconPhrase": "przewa\u017cnie s\u0142onecznie",
+ "HasPrecipitation": false,
+ "IsDaylight": true,
+ "Temperature": {
+ "Value": 22.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 22.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 20.8,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "WetBulbTemperature": {
+ "Value": 15.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 19.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 11.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 14.8,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 239,
+ "Localized": "WSW",
+ "English": "WSW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 24.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 48,
+ "IndoorRelativeHumidity": 48,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 10058.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 2,
+ "UVIndexFloat": 2.4,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 1,
+ "ThunderstormProbability": 0,
+ "RainProbability": 1,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 13,
+ "Evapotranspiration": {
+ "Value": 0.3,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 525.5,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 9.0
+ },
+ {
+ "DateTime": "2025-09-12t17:00:00+02:00",
+ "EpochDateTime": 1757689200,
+ "WeatherIcon": 2,
+ "IconPhrase": "przewa\u017cnie s\u0142onecznie",
+ "HasPrecipitation": false,
+ "IsDaylight": true,
+ "Temperature": {
+ "Value": 23.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 22.9,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 21.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "WetBulbTemperature": {
+ "Value": 16.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 20.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 11.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 238,
+ "Localized": "WSW",
+ "English": "WSW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 22.2,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 48,
+ "IndoorRelativeHumidity": 48,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 2,
+ "UVIndexFloat": 1.7,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 1,
+ "ThunderstormProbability": 0,
+ "RainProbability": 1,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 17,
+ "Evapotranspiration": {
+ "Value": 0.3,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 386.6,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 9.0
+ },
+ {
+ "DateTime": "2025-09-12t18:00:00+02:00",
+ "EpochDateTime": 1757692800,
+ "WeatherIcon": 2,
+ "IconPhrase": "przewa\u017cnie s\u0142onecznie",
+ "HasPrecipitation": false,
+ "IsDaylight": true,
+ "Temperature": {
+ "Value": 21.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 20.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 20.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "WetBulbTemperature": {
+ "Value": 15.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 19.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 232,
+ "Localized": "SW",
+ "English": "SW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 18.5,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 56,
+ "IndoorRelativeHumidity": 56,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 1,
+ "UVIndexFloat": 1.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 1,
+ "ThunderstormProbability": 0,
+ "RainProbability": 1,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 23,
+ "Evapotranspiration": {
+ "Value": 0.3,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 224.7,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 7.0
+ },
+ {
+ "DateTime": "2025-09-12t19:00:00+02:00",
+ "EpochDateTime": 1757696400,
+ "WeatherIcon": 2,
+ "IconPhrase": "przewa\u017cnie s\u0142onecznie",
+ "HasPrecipitation": false,
+ "IsDaylight": true,
+ "Temperature": {
+ "Value": 19.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 18.2,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 18.2,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "WetBulbTemperature": {
+ "Value": 15.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 17.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 224,
+ "Localized": "SW",
+ "English": "SW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 16.7,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 62,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.2,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 2,
+ "ThunderstormProbability": 0,
+ "RainProbability": 2,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 29,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 52.2,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 2.0
+ },
+ {
+ "DateTime": "2025-09-12t20:00:00+02:00",
+ "EpochDateTime": 1757700000,
+ "WeatherIcon": 35,
+ "IconPhrase": "zachmurzenie umiarkowane",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 17.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 16.7,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 16.7,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Przyjemnie"
+ },
+ "WetBulbTemperature": {
+ "Value": 14.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 16.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 11.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 219,
+ "Localized": "SW",
+ "English": "SW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 14.8,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 69,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 3,
+ "ThunderstormProbability": 0,
+ "RainProbability": 3,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 34,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-12t21:00:00+02:00",
+ "EpochDateTime": 1757703600,
+ "WeatherIcon": 35,
+ "IconPhrase": "zachmurzenie umiarkowane",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 15.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 14.9,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 14.9,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 15.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 11.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 11.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 230,
+ "Localized": "SW",
+ "English": "SW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 77,
+ "IndoorRelativeHumidity": 59,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 3,
+ "ThunderstormProbability": 0,
+ "RainProbability": 3,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 30,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-12t22:00:00+02:00",
+ "EpochDateTime": 1757707200,
+ "WeatherIcon": 34,
+ "IconPhrase": "przewa\u017cnie bezchmurnie",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 14.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.8,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.8,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 14.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 259,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 84,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 3,
+ "ThunderstormProbability": 0,
+ "RainProbability": 3,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 26,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-12t23:00:00+02:00",
+ "EpochDateTime": 1757710800,
+ "WeatherIcon": 34,
+ "IconPhrase": "przewa\u017cnie bezchmurnie",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 14.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.8,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.8,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 14.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 272,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 86,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 16.1,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 4,
+ "ThunderstormProbability": 0,
+ "RainProbability": 4,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 22,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-13t00:00:00+02:00",
+ "EpochDateTime": 1757714400,
+ "WeatherIcon": 35,
+ "IconPhrase": "zachmurzenie umiarkowane",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 13.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.5,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.5,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 14.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 265,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 89,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 11.3,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 4,
+ "ThunderstormProbability": 0,
+ "RainProbability": 4,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 48,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-13t01:00:00+02:00",
+ "EpochDateTime": 1757718000,
+ "WeatherIcon": 36,
+ "IconPhrase": "przej\u015bciowe zachmurzenia",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 13.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.2,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.2,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 14.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 256,
+ "Localized": "WSW",
+ "English": "WSW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 11.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 91,
+ "IndoorRelativeHumidity": 61,
+ "Visibility": {
+ "Value": 11.3,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 4,
+ "ThunderstormProbability": 0,
+ "RainProbability": 4,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 74,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-13t02:00:00+02:00",
+ "EpochDateTime": 1757721600,
+ "WeatherIcon": 7,
+ "IconPhrase": "pochmurno",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 13.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.5,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.5,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 13.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 244,
+ "Localized": "WSW",
+ "English": "WSW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 11.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 90,
+ "IndoorRelativeHumidity": 61,
+ "Visibility": {
+ "Value": 9.7,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 9144.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 5,
+ "ThunderstormProbability": 0,
+ "RainProbability": 5,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 100,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ },
+ {
+ "DateTime": "2025-09-13t03:00:00+02:00",
+ "EpochDateTime": 1757725200,
+ "WeatherIcon": 7,
+ "IconPhrase": "pochmurno",
+ "HasPrecipitation": false,
+ "IsDaylight": false,
+ "Temperature": {
+ "Value": 14.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperature": {
+ "Value": 13.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "RealFeelTemperatureShade": {
+ "Value": 13.6,
+ "Unit": "C",
+ "UnitType": 17,
+ "Phrase": "Ch\u0142odno"
+ },
+ "WetBulbTemperature": {
+ "Value": 13.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "WetBulbGlobeTemperature": {
+ "Value": 13.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "DewPoint": {
+ "Value": 12.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Wind": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 229,
+ "Localized": "SW",
+ "English": "SW"
+ }
+ },
+ "WindGust": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ }
+ },
+ "RelativeHumidity": 89,
+ "IndoorRelativeHumidity": 60,
+ "Visibility": {
+ "Value": 9.7,
+ "Unit": "km",
+ "UnitType": 6
+ },
+ "Ceiling": {
+ "Value": 7376.0,
+ "Unit": "m",
+ "UnitType": 5
+ },
+ "UVIndex": 0,
+ "UVIndexFloat": 0.0,
+ "UVIndexText": "niskie",
+ "PrecipitationProbability": 5,
+ "ThunderstormProbability": 0,
+ "RainProbability": 5,
+ "SnowProbability": 0,
+ "IceProbability": 0,
+ "TotalLiquid": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Rain": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "Snow": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "Ice": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "CloudCover": 98,
+ "Evapotranspiration": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SolarIrradiance": {
+ "Value": 0.0,
+ "Unit": "W/m\u00b2",
+ "UnitType": 33
+ },
+ "AccuLumenBrightnessIndex": 0.0
+ }
+]
diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr
index 254667d7809..ae17c76511c 100644
--- a/tests/components/accuweather/snapshots/test_weather.ambr
+++ b/tests/components/accuweather/snapshots/test_weather.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_forecast_service[get_forecasts]
+# name: test_forecast_service[daily]
dict({
'weather.home': dict({
'forecast': list([
@@ -82,6 +82,182 @@
}),
})
# ---
+# name: test_forecast_service[hourly]
+ dict({
+ 'weather.home': dict({
+ 'forecast': list([
+ dict({
+ 'apparent_temperature': 22.6,
+ 'cloud_coverage': 13,
+ 'condition': 'sunny',
+ 'datetime': '2025-09-12T14:00:00+00:00',
+ 'humidity': 48,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 1,
+ 'temperature': 22.5,
+ 'uv_index': 2,
+ 'wind_bearing': 239,
+ 'wind_gust_speed': 24.1,
+ 'wind_speed': 14.8,
+ }),
+ dict({
+ 'apparent_temperature': 22.9,
+ 'cloud_coverage': 17,
+ 'condition': 'sunny',
+ 'datetime': '2025-09-12T15:00:00+00:00',
+ 'humidity': 48,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 1,
+ 'temperature': 23.1,
+ 'uv_index': 2,
+ 'wind_bearing': 238,
+ 'wind_gust_speed': 22.2,
+ 'wind_speed': 13.0,
+ }),
+ dict({
+ 'apparent_temperature': 20.6,
+ 'cloud_coverage': 23,
+ 'condition': 'sunny',
+ 'datetime': '2025-09-12T16:00:00+00:00',
+ 'humidity': 56,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 1,
+ 'temperature': 21.3,
+ 'uv_index': 1,
+ 'wind_bearing': 232,
+ 'wind_gust_speed': 18.5,
+ 'wind_speed': 13.0,
+ }),
+ dict({
+ 'apparent_temperature': 18.2,
+ 'cloud_coverage': 29,
+ 'condition': 'sunny',
+ 'datetime': '2025-09-12T17:00:00+00:00',
+ 'humidity': 62,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 2,
+ 'temperature': 19.5,
+ 'uv_index': 0,
+ 'wind_bearing': 224,
+ 'wind_gust_speed': 16.7,
+ 'wind_speed': 13.0,
+ }),
+ dict({
+ 'apparent_temperature': 16.7,
+ 'cloud_coverage': 34,
+ 'condition': 'partlycloudy',
+ 'datetime': '2025-09-12T18:00:00+00:00',
+ 'humidity': 69,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 3,
+ 'temperature': 17.7,
+ 'uv_index': 0,
+ 'wind_bearing': 219,
+ 'wind_gust_speed': 14.8,
+ 'wind_speed': 11.1,
+ }),
+ dict({
+ 'apparent_temperature': 14.9,
+ 'cloud_coverage': 30,
+ 'condition': 'partlycloudy',
+ 'datetime': '2025-09-12T19:00:00+00:00',
+ 'humidity': 77,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 3,
+ 'temperature': 15.8,
+ 'uv_index': 0,
+ 'wind_bearing': 230,
+ 'wind_gust_speed': 13.0,
+ 'wind_speed': 11.1,
+ }),
+ dict({
+ 'apparent_temperature': 13.8,
+ 'cloud_coverage': 26,
+ 'condition': 'clear-night',
+ 'datetime': '2025-09-12T20:00:00+00:00',
+ 'humidity': 84,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 3,
+ 'temperature': 14.6,
+ 'uv_index': 0,
+ 'wind_bearing': 259,
+ 'wind_gust_speed': 13.0,
+ 'wind_speed': 9.3,
+ }),
+ dict({
+ 'apparent_temperature': 13.8,
+ 'cloud_coverage': 22,
+ 'condition': 'clear-night',
+ 'datetime': '2025-09-12T21:00:00+00:00',
+ 'humidity': 86,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 4,
+ 'temperature': 14.4,
+ 'uv_index': 0,
+ 'wind_bearing': 272,
+ 'wind_gust_speed': 13.0,
+ 'wind_speed': 9.3,
+ }),
+ dict({
+ 'apparent_temperature': 13.5,
+ 'cloud_coverage': 48,
+ 'condition': 'partlycloudy',
+ 'datetime': '2025-09-12T22:00:00+00:00',
+ 'humidity': 89,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 4,
+ 'temperature': 13.9,
+ 'uv_index': 0,
+ 'wind_bearing': 265,
+ 'wind_gust_speed': 13.0,
+ 'wind_speed': 7.4,
+ }),
+ dict({
+ 'apparent_temperature': 13.2,
+ 'cloud_coverage': 74,
+ 'condition': 'partlycloudy',
+ 'datetime': '2025-09-12T23:00:00+00:00',
+ 'humidity': 91,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 4,
+ 'temperature': 13.6,
+ 'uv_index': 0,
+ 'wind_bearing': 256,
+ 'wind_gust_speed': 11.1,
+ 'wind_speed': 7.4,
+ }),
+ dict({
+ 'apparent_temperature': 13.5,
+ 'cloud_coverage': 100,
+ 'condition': 'cloudy',
+ 'datetime': '2025-09-13T00:00:00+00:00',
+ 'humidity': 90,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 5,
+ 'temperature': 13.9,
+ 'uv_index': 0,
+ 'wind_bearing': 244,
+ 'wind_gust_speed': 11.1,
+ 'wind_speed': 7.4,
+ }),
+ dict({
+ 'apparent_temperature': 13.6,
+ 'cloud_coverage': 98,
+ 'condition': 'cloudy',
+ 'datetime': '2025-09-13T01:00:00+00:00',
+ 'humidity': 89,
+ 'precipitation': 0.0,
+ 'precipitation_probability': 5,
+ 'temperature': 14.0,
+ 'uv_index': 0,
+ 'wind_bearing': 229,
+ 'wind_gust_speed': 9.3,
+ 'wind_speed': 7.4,
+ }),
+ ]),
+ }),
+ })
+# ---
# name: test_forecast_subscription
list([
dict({
@@ -269,7 +445,7 @@
'platform': 'accuweather',
'previous_unique_id': None,
'suggested_object_id': None,
- 'supported_features': ,
+ 'supported_features': ,
'translation_key': None,
'unique_id': '0123456',
'unit_of_measurement': None,
@@ -287,7 +463,7 @@
'precipitation_unit': ,
'pressure': 1012.0,
'pressure_unit': ,
- 'supported_features': ,
+ 'supported_features': ,
'temperature': 22.6,
'temperature_unit': ,
'uv_index': 6,
diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py
index abe1be61905..f17f4362aca 100644
--- a/tests/components/accuweather/test_config_flow.py
+++ b/tests/components/accuweather/test_config_flow.py
@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock
from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError
+import pytest
from homeassistant.components.accuweather.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
@@ -10,6 +11,8 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
+from . import init_integration
+
from tests.common import MockConfigEntry
VALID_CONFIG = {
@@ -30,24 +33,6 @@ async def test_show_form(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
-async def test_api_key_too_short(hass: HomeAssistant) -> None:
- """Test that errors are shown when API key is too short."""
- # The API key length check is done by the library without polling the AccuWeather
- # server so we don't need to patch the library method.
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_USER},
- data={
- CONF_NAME: "abcd",
- CONF_API_KEY: "foo",
- CONF_LATITUDE: 55.55,
- CONF_LONGITUDE: 122.12,
- },
- )
-
- assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
-
-
async def test_invalid_api_key(
hass: HomeAssistant, mock_accuweather_client: AsyncMock
) -> None:
@@ -105,7 +90,7 @@ async def test_integration_already_exists(
"""Test we only allow a single config flow."""
MockConfigEntry(
domain=DOMAIN,
- unique_id="123456",
+ unique_id="0123456",
data=VALID_CONFIG,
).add_to_hass(hass)
@@ -116,7 +101,7 @@ async def test_integration_already_exists(
)
assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "single_instance_allowed"
+ assert result["reason"] == "already_configured"
async def test_create_entry(
@@ -135,3 +120,64 @@ async def test_create_entry(
assert result["data"][CONF_LATITUDE] == 55.55
assert result["data"][CONF_LONGITUDE] == 122.12
assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw"
+
+
+async def test_reauth_successful(
+ hass: HomeAssistant, mock_accuweather_client: AsyncMock
+) -> None:
+ """Test starting a reauthentication flow."""
+ mock_config_entry = await init_integration(hass)
+
+ result = await mock_config_entry.start_reauth_flow(hass)
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_API_KEY: "new_api_key"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
+
+
+@pytest.mark.parametrize(
+ ("exc", "base_error"),
+ [
+ (ApiError("API Error"), "cannot_connect"),
+ (InvalidApiKeyError("Invalid API Key"), "invalid_api_key"),
+ (TimeoutError, "cannot_connect"),
+ (RequestsExceededError("Requests Exceeded"), "requests_exceeded"),
+ ],
+)
+async def test_reauth_errors(
+ hass: HomeAssistant,
+ exc: Exception,
+ base_error: str,
+ mock_accuweather_client: AsyncMock,
+) -> None:
+ """Test reauthentication flow with errors."""
+ mock_config_entry = await init_integration(hass)
+
+ result = await mock_config_entry.start_reauth_flow(hass)
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ mock_accuweather_client.async_get_location.side_effect = exc
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_API_KEY: "new_api_key"},
+ )
+
+ assert result["errors"] == {"base": base_error}
+
+ mock_accuweather_client.async_get_location.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_API_KEY: "new_api_key"},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py
index f88cde88e7e..f79ddaebb30 100644
--- a/tests/components/accuweather/test_init.py
+++ b/tests/components/accuweather/test_init.py
@@ -1,8 +1,9 @@
"""Test init of AccuWeather integration."""
+from datetime import timedelta
from unittest.mock import AsyncMock
-from accuweather import ApiError
+from accuweather import ApiError, InvalidApiKeyError
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.accuweather.const import (
@@ -11,7 +12,7 @@ from homeassistant.components.accuweather.const import (
UPDATE_INTERVAL_OBSERVATION,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.config_entries import ConfigEntryState
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -118,3 +119,60 @@ async def test_remove_ozone_sensors(
entry = entity_registry.async_get("sensor.home_ozone_0d")
assert entry is None
+
+
+async def test_auth_error(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_accuweather_client: AsyncMock,
+) -> None:
+ """Test authentication error when polling data."""
+ mock_accuweather_client.async_get_current_conditions.side_effect = (
+ InvalidApiKeyError("Invalid API Key")
+ )
+
+ mock_config_entry = await init_integration(hass)
+
+ assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+
+ flow = flows[0]
+ assert flow.get("step_id") == "reauth_confirm"
+ assert flow.get("handler") == DOMAIN
+
+ assert "context" in flow
+ assert flow["context"].get("source") == SOURCE_REAUTH
+ assert flow["context"].get("entry_id") == mock_config_entry.entry_id
+
+
+async def test_auth_error_whe_polling_data(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_accuweather_client: AsyncMock,
+) -> None:
+ """Test authentication error when polling data."""
+ mock_config_entry = await init_integration(hass)
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ mock_accuweather_client.async_get_current_conditions.side_effect = (
+ InvalidApiKeyError("Invalid API Key")
+ )
+ freezer.tick(timedelta(minutes=10))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+
+ flow = flows[0]
+ assert flow.get("step_id") == "reauth_confirm"
+ assert flow.get("handler") == DOMAIN
+
+ assert "context" in flow
+ assert flow["context"].get("source") == SOURCE_REAUTH
+ assert flow["context"].get("entry_id") == mock_config_entry.entry_id
diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py
index 855c9f3e4d5..69035d63990 100644
--- a/tests/components/accuweather/test_sensor.py
+++ b/tests/components/accuweather/test_sensor.py
@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock, patch
-from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError
+from accuweather import ApiError, RequestsExceededError
from aiohttp.client_exceptions import ClientConnectorError
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -86,7 +86,6 @@ async def test_availability(
ApiError("API Error"),
ConnectionError,
ClientConnectorError,
- InvalidApiKeyError("Invalid API key"),
RequestsExceededError("Requests exceeded"),
],
)
diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py
index a23b09fec29..7e163e40d83 100644
--- a/tests/components/accuweather/test_weather.py
+++ b/tests/components/accuweather/test_weather.py
@@ -107,24 +107,24 @@ async def test_unsupported_condition_icon_data(
@pytest.mark.parametrize(
- ("service"),
- [SERVICE_GET_FORECASTS],
+ ("forecast_type"),
+ ["daily", "hourly"],
)
async def test_forecast_service(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_accuweather_client: AsyncMock,
- service: str,
+ forecast_type: str,
) -> None:
"""Test multiple forecast."""
await init_integration(hass)
response = await hass.services.async_call(
WEATHER_DOMAIN,
- service,
+ SERVICE_GET_FORECASTS,
{
"entity_id": "weather.home",
- "type": "daily",
+ "type": forecast_type,
},
blocking=True,
return_response=True,
diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py
index 05d34b15ddc..ceffb7c055e 100644
--- a/tests/components/ai_task/conftest.py
+++ b/tests/components/ai_task/conftest.py
@@ -10,6 +10,8 @@ from homeassistant.components.ai_task import (
AITaskEntityFeature,
GenDataTask,
GenDataTaskResult,
+ GenImageTask,
+ GenImageTaskResult,
)
from homeassistant.components.conversation import AssistantContent, ChatLog
from homeassistant.config_entries import ConfigEntry, ConfigFlow
@@ -36,13 +38,16 @@ class MockAITaskEntity(AITaskEntity):
_attr_name = "Test Task Entity"
_attr_supported_features = (
- AITaskEntityFeature.GENERATE_DATA | AITaskEntityFeature.SUPPORT_ATTACHMENTS
+ AITaskEntityFeature.GENERATE_DATA
+ | AITaskEntityFeature.SUPPORT_ATTACHMENTS
+ | AITaskEntityFeature.GENERATE_IMAGE
)
def __init__(self) -> None:
"""Initialize the mock entity."""
super().__init__()
self.mock_generate_data_tasks = []
+ self.mock_generate_image_tasks = []
async def _async_generate_data(
self, task: GenDataTask, chat_log: ChatLog
@@ -63,6 +68,24 @@ class MockAITaskEntity(AITaskEntity):
data=data,
)
+ async def _async_generate_image(
+ self, task: GenImageTask, chat_log: ChatLog
+ ) -> GenImageTaskResult:
+ """Mock handling of generate image task."""
+ self.mock_generate_image_tasks.append(task)
+ chat_log.async_add_assistant_content_without_tools(
+ AssistantContent(self.entity_id, "")
+ )
+ return GenImageTaskResult(
+ conversation_id=chat_log.conversation_id,
+ image_data=b"mock_image_data",
+ mime_type="image/png",
+ width=1536,
+ height=1024,
+ model="mock_model",
+ revised_prompt="mock_revised_prompt",
+ )
+
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
@@ -134,4 +157,4 @@ async def init_components(
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
- await hass.async_block_till_done()
+ await hass.async_block_till_done(wait_background_tasks=True)
diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py
index a2eecfddf74..545dce0c1c2 100644
--- a/tests/components/ai_task/test_http.py
+++ b/tests/components/ai_task/test_http.py
@@ -19,6 +19,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": None,
+ "gen_image_entity_id": None,
}
# Set preferences
@@ -32,6 +33,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_1",
+ "gen_image_entity_id": None,
}
# Get updated preferences
@@ -40,6 +42,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_1",
+ "gen_image_entity_id": None,
}
# Update an existing preference
@@ -53,6 +56,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_2",
+ "gen_image_entity_id": None,
}
# Get updated preferences
@@ -61,6 +65,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_2",
+ "gen_image_entity_id": None,
}
# No preferences set will preserve existing preferences
@@ -73,6 +78,7 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_2",
+ "gen_image_entity_id": None,
}
# Get updated preferences
@@ -81,4 +87,43 @@ async def test_ws_preferences(
assert msg["success"]
assert msg["result"] == {
"gen_data_entity_id": "ai_task.summary_2",
+ "gen_image_entity_id": None,
+ }
+
+ # Set gen_image_entity_id preference
+ await client.send_json_auto_id(
+ {
+ "type": "ai_task/preferences/set",
+ "gen_image_entity_id": "ai_task.image_gen_1",
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {
+ "gen_data_entity_id": "ai_task.summary_2",
+ "gen_image_entity_id": "ai_task.image_gen_1",
+ }
+
+ # Update both preferences
+ await client.send_json_auto_id(
+ {
+ "type": "ai_task/preferences/set",
+ "gen_data_entity_id": "ai_task.summary_3",
+ "gen_image_entity_id": "ai_task.image_gen_2",
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {
+ "gen_data_entity_id": "ai_task.summary_3",
+ "gen_image_entity_id": "ai_task.image_gen_2",
+ }
+
+ # Get final preferences
+ await client.send_json_auto_id({"type": "ai_task/preferences/get"})
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {
+ "gen_data_entity_id": "ai_task.summary_3",
+ "gen_image_entity_id": "ai_task.image_gen_2",
}
diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py
index 09ee926c187..83e1808b6d8 100644
--- a/tests/components/ai_task/test_init.py
+++ b/tests/components/ai_task/test_init.py
@@ -4,14 +4,16 @@ from pathlib import Path
from typing import Any
from unittest.mock import patch
+from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.ai_task import AITaskPreferences
-from homeassistant.components.ai_task.const import DATA_PREFERENCES
+from homeassistant.components.ai_task.const import DATA_MEDIA_SOURCE, DATA_PREFERENCES
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
@@ -277,3 +279,82 @@ async def test_generate_data_service_invalid_structure(
blocking=True,
return_response=True,
)
+
+
+@pytest.mark.parametrize(
+ ("set_preferences", "msg_extra"),
+ [
+ ({}, {"entity_id": TEST_ENTITY_ID}),
+ ({"gen_image_entity_id": TEST_ENTITY_ID}, {}),
+ (
+ {"gen_image_entity_id": "ai_task.other_entity"},
+ {"entity_id": TEST_ENTITY_ID},
+ ),
+ ],
+)
+@freeze_time("2025-06-14 22:59:00")
+async def test_generate_image_service(
+ hass: HomeAssistant,
+ init_components: None,
+ set_preferences: dict[str, str | None],
+ msg_extra: dict[str, str],
+ mock_ai_task_entity: MockAITaskEntity,
+) -> None:
+ """Test the generate image service."""
+ preferences = hass.data[DATA_PREFERENCES]
+ preferences.async_set_preferences(**set_preferences)
+
+ with patch.object(
+ hass.data[DATA_MEDIA_SOURCE],
+ "async_upload_media",
+ return_value="media-source://ai_task/image/2025-06-14_225900_test_task.png",
+ ) as mock_upload_media:
+ result = await hass.services.async_call(
+ "ai_task",
+ "generate_image",
+ {
+ "task_name": "Test Image",
+ "instructions": "Generate a test image",
+ }
+ | msg_extra,
+ blocking=True,
+ return_response=True,
+ )
+
+ mock_upload_media.assert_called_once()
+ assert "image_data" not in result
+ assert (
+ result["media_source_id"]
+ == "media-source://ai_task/image/2025-06-14_225900_test_task.png"
+ )
+ assert result["url"].startswith(
+ "/ai_task/image/2025-06-14_225900_test_task.png?authSig="
+ )
+ assert result["mime_type"] == "image/png"
+ assert result["model"] == "mock_model"
+ assert result["revised_prompt"] == "mock_revised_prompt"
+
+ assert len(mock_ai_task_entity.mock_generate_image_tasks) == 1
+ task = mock_ai_task_entity.mock_generate_image_tasks[0]
+ assert task.instructions == "Generate a test image"
+
+
+async def test_generate_image_service_no_entity(
+ hass: HomeAssistant,
+ init_components: None,
+) -> None:
+ """Test the generate image service with no entity specified."""
+ with pytest.raises(
+ HomeAssistantError,
+ match="No entity_id provided and no preferred entity set",
+ ):
+ await hass.services.async_call(
+ "ai_task",
+ "generate_image",
+ {
+ "task_name": "Test Image",
+ "instructions": "Generate a test image",
+ },
+ blocking=True,
+ return_response=True,
+ )
diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py
new file mode 100644
index 00000000000..11344acfb5e
--- /dev/null
+++ b/tests/components/ai_task/test_media_source.py
@@ -0,0 +1,35 @@
+"""Test ai_task media source."""
+
+import pytest
+
+from homeassistant.components import media_source
+from homeassistant.components.ai_task.media_source import async_get_media_source
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+
+async def test_local_media_source(hass: HomeAssistant, init_components: None) -> None:
+ """Test that the image media source is created."""
+ item = await media_source.async_browse_media(hass, "media-source://")
+
+ assert any(c.title == "AI Generated Images" for c in item.children)
+
+ source = await async_get_media_source(hass)
+ assert isinstance(source, media_source.local_source.LocalSource)
+ assert source.name == "AI Generated Images"
+ assert source.domain == "ai_task"
+ assert list(source.media_dirs) == ["image"]
+ # Depending on Docker, the default is one of the two paths
+ assert source.media_dirs["image"] in (
+ "/media/ai_task/image",
+ hass.config.path("media/ai_task/image"),
+ )
+ assert source.url_prefix == "/ai_task"
+
+ hass.config.media_dirs = {}
+
+ with pytest.raises(
+ HomeAssistantError,
+ match="AI Task media source requires at least one media directory configured",
+ ):
+ await async_get_media_source(hass)
diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py
index 7eb75b62bb0..4f8616d3f81 100644
--- a/tests/components/ai_task/test_task.py
+++ b/tests/components/ai_task/test_task.py
@@ -9,13 +9,18 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import media_source
-from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data
+from homeassistant.components.ai_task import (
+ AITaskEntityFeature,
+ async_generate_data,
+ async_generate_image,
+)
+from homeassistant.components.ai_task.const import DATA_MEDIA_SOURCE
from homeassistant.components.camera import Image
from homeassistant.components.conversation import async_get_chat_log
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import chat_session
+from homeassistant.helpers import chat_session, llm
from homeassistant.util import dt as dt_util
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
@@ -73,10 +78,12 @@ async def test_generate_data_preferred_entity(
assert state is not None
assert state.state == STATE_UNKNOWN
+ llm_api = llm.AssistAPI(hass)
result = await async_generate_data(
hass,
task_name="Test Task",
instructions="Test prompt",
+ llm_api=llm_api,
)
assert result.data == "Mock result"
as_dict = result.as_dict()
@@ -86,6 +93,12 @@ async def test_generate_data_preferred_entity(
assert state is not None
assert state.state != STATE_UNKNOWN
+ with (
+ chat_session.async_get_chat_session(hass, result.conversation_id) as session,
+ async_get_chat_log(hass, session) as chat_log,
+ ):
+ assert chat_log.llm_api.api is llm_api
+
mock_ai_task_entity.supported_features = AITaskEntityFeature(0)
with pytest.raises(
HomeAssistantError,
@@ -174,7 +187,11 @@ async def test_generate_data_mixed_attachments(
patch(
"homeassistant.components.camera.async_get_image",
return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"),
- ) as mock_get_image,
+ ) as mock_get_camera_image,
+ patch(
+ "homeassistant.components.image.async_get_image",
+ return_value=Image(content_type="image/jpeg", content=b"fake_image_jpeg"),
+ ) as mock_get_image_image,
patch(
"homeassistant.components.media_source.async_resolve_media",
return_value=media_source.PlayMedia(
@@ -194,6 +211,10 @@ async def test_generate_data_mixed_attachments(
"media_content_id": "media-source://camera/camera.front_door",
"media_content_type": "image/jpeg",
},
+ {
+ "media_content_id": "media-source://image/image.floorplan",
+ "media_content_type": "image/jpeg",
+ },
{
"media_content_id": "media-source://media_player/video.mp4",
"media_content_type": "video/mp4",
@@ -202,7 +223,8 @@ async def test_generate_data_mixed_attachments(
)
# Verify both methods were called
- mock_get_image.assert_called_once_with(hass, "camera.front_door")
+ mock_get_camera_image.assert_called_once_with(hass, "camera.front_door")
+ mock_get_image_image.assert_called_once_with(hass, "image.floorplan")
mock_resolve_media.assert_called_once_with(
hass, "media-source://media_player/video.mp4", None
)
@@ -211,7 +233,7 @@ async def test_generate_data_mixed_attachments(
assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1
task = mock_ai_task_entity.mock_generate_data_tasks[0]
assert task.attachments is not None
- assert len(task.attachments) == 2
+ assert len(task.attachments) == 3
# Check camera attachment
camera_attachment = task.attachments[0]
@@ -227,18 +249,95 @@ async def test_generate_data_mixed_attachments(
content = await hass.async_add_executor_job(camera_attachment.path.read_bytes)
assert content == b"fake_camera_jpeg"
+ # Check image attachment
+ image_attachment = task.attachments[1]
+ assert image_attachment.media_content_id == "media-source://image/image.floorplan"
+ assert image_attachment.mime_type == "image/jpeg"
+ assert isinstance(image_attachment.path, Path)
+ assert image_attachment.path.suffix == ".jpg"
+
+ # Verify image snapshot content
+ assert image_attachment.path.exists()
+ content = await hass.async_add_executor_job(image_attachment.path.read_bytes)
+ assert content == b"fake_image_jpeg"
+
# Trigger clean up
async_fire_time_changed(
hass,
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1),
)
- await hass.async_block_till_done()
+ await hass.async_block_till_done(wait_background_tasks=True)
# Verify the temporary file cleaned up
assert not camera_attachment.path.exists()
+ assert not image_attachment.path.exists()
# Check regular media attachment
- media_attachment = task.attachments[1]
+ media_attachment = task.attachments[2]
assert media_attachment.media_content_id == "media-source://media_player/video.mp4"
assert media_attachment.mime_type == "video/mp4"
assert media_attachment.path == Path("/media/test.mp4")
+
+
+@freeze_time("2025-06-14 22:59:00")
+async def test_generate_image(
+ hass: HomeAssistant,
+ init_components: None,
+ mock_ai_task_entity: MockAITaskEntity,
+) -> None:
+ """Test generating image service."""
+ with pytest.raises(
+ HomeAssistantError, match="AI Task entity ai_task.unknown not found"
+ ):
+ await async_generate_image(
+ hass,
+ task_name="Test Task",
+ entity_id="ai_task.unknown",
+ instructions="Test prompt",
+ )
+
+ state = hass.states.get(TEST_ENTITY_ID)
+ assert state is not None
+ assert state.state == STATE_UNKNOWN
+
+ with patch.object(
+ hass.data[DATA_MEDIA_SOURCE],
+ "async_upload_media",
+ return_value="media-source://ai_task/image/2025-06-14_225900_test_task.png",
+ ) as mock_upload_media:
+ result = await async_generate_image(
+ hass,
+ task_name="Test Task",
+ entity_id=TEST_ENTITY_ID,
+ instructions="Test prompt",
+ )
+ mock_upload_media.assert_called_once()
+ assert "image_data" not in result
+ assert (
+ result["media_source_id"]
+ == "media-source://ai_task/image/2025-06-14_225900_test_task.png"
+ )
+ assert result["url"].startswith(
+ "/ai_task/image/2025-06-14_225900_test_task.png?authSig="
+ )
+ assert result["mime_type"] == "image/png"
+ assert result["model"] == "mock_model"
+ assert result["revised_prompt"] == "mock_revised_prompt"
+ assert result["height"] == 1024
+ assert result["width"] == 1536
+
+ state = hass.states.get(TEST_ENTITY_ID)
+ assert state is not None
+ assert state.state != STATE_UNKNOWN
+
+ mock_ai_task_entity.supported_features = AITaskEntityFeature(0)
+ with pytest.raises(
+ HomeAssistantError,
+ match="AI Task entity ai_task.test_task_entity does not support generating images",
+ ):
+ await async_generate_image(
+ hass,
+ task_name="Test Task",
+ entity_id=TEST_ENTITY_ID,
+ instructions="Test prompt",
+ )
diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py
index 5443f79a976..8c341a670d2 100644
--- a/tests/components/airos/conftest.py
+++ b/tests/components/airos/conftest.py
@@ -1,9 +1,9 @@
"""Common fixtures for the Ubiquiti airOS tests."""
from collections.abc import Generator
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
-from airos.airos8 import AirOSData
+from airos.airos8 import AirOS8Data
import pytest
from homeassistant.components.airos.const import DOMAIN
@@ -16,7 +16,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture
def ap_fixture():
"""Load fixture data for AP mode."""
json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN)
- return AirOSData.from_dict(json_data)
+ return AirOS8Data.from_dict(json_data)
@pytest.fixture
@@ -28,22 +28,26 @@ def mock_setup_entry() -> Generator[AsyncMock]:
yield mock_setup_entry
+@pytest.fixture
+def mock_airos_class() -> Generator[MagicMock]:
+ """Fixture to mock the AirOS class itself."""
+ with (
+ patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class,
+ patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class),
+ patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class),
+ ):
+ yield mock_class
+
+
@pytest.fixture
def mock_airos_client(
- request: pytest.FixtureRequest, ap_fixture: AirOSData
+ mock_airos_class: MagicMock, ap_fixture: AirOS8Data
) -> Generator[AsyncMock]:
"""Fixture to mock the AirOS API client."""
- with (
- patch(
- "homeassistant.components.airos.config_flow.AirOS", autospec=True
- ) as mock_airos,
- patch("homeassistant.components.airos.coordinator.AirOS", new=mock_airos),
- patch("homeassistant.components.airos.AirOS", new=mock_airos),
- ):
- client = mock_airos.return_value
- client.status.return_value = ap_fixture
- client.login.return_value = True
- yield client
+ client = mock_airos_class.return_value
+ client.status.return_value = ap_fixture
+ client.login.return_value = True
+ return client
@pytest.fixture
diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr
index f4561ec6d99..4e94beae473 100644
--- a/tests/components/airos/snapshots/test_diagnostics.ambr
+++ b/tests/components/airos/snapshots/test_diagnostics.ambr
@@ -632,6 +632,10 @@
}),
}),
'entry_data': dict({
+ 'advanced_settings': dict({
+ 'ssl': True,
+ 'verify_ssl': False,
+ }),
'host': '**REDACTED**',
'password': '**REDACTED**',
'username': 'ubnt',
diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py
index 212c80dfc2b..8f668166ea6 100644
--- a/tests/components/airos/test_config_flow.py
+++ b/tests/components/airos/test_config_flow.py
@@ -10,18 +10,36 @@ from airos.exceptions import (
)
import pytest
-from homeassistant.components.airos.const import DOMAIN
+from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS
from homeassistant.config_entries import SOURCE_USER
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
+NEW_PASSWORD = "new_password"
+REAUTH_STEP = "reauth_confirm"
+
MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
+ SECTION_ADVANCED_SETTINGS: {
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: False,
+ },
+}
+MOCK_CONFIG_REAUTH = {
+ CONF_HOST: "1.1.1.1",
+ CONF_USERNAME: "ubnt",
+ CONF_PASSWORD: "wrong-password",
}
@@ -33,7 +51,8 @@ async def test_form_creates_entry(
) -> None:
"""Test we get the form and create the appropriate entry."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}
+ DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
@@ -78,7 +97,6 @@ async def test_form_duplicate_entry(
@pytest.mark.parametrize(
("exception", "error"),
[
- (AirOSConnectionAuthenticationError, "invalid_auth"),
(AirOSDeviceConnectionError, "cannot_connect"),
(AirOSKeyDataMissingError, "key_data_missing"),
(Exception, "unknown"),
@@ -117,3 +135,121 @@ async def test_form_exception_handling(
assert result["title"] == "NanoStation 5AC ap name"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_reauth_flow_scenario(
+ hass: HomeAssistant,
+ mock_airos_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test successful reauthentication."""
+ mock_config_entry.add_to_hass(hass)
+
+ mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+
+ flow = flows[0]
+ assert flow["step_id"] == REAUTH_STEP
+
+ mock_airos_client.login.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ flow["flow_id"],
+ user_input={CONF_PASSWORD: NEW_PASSWORD},
+ )
+
+ # Always test resolution
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+
+ updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
+ assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
+
+
+@pytest.mark.parametrize(
+ ("reauth_exception", "expected_error"),
+ [
+ (AirOSConnectionAuthenticationError, "invalid_auth"),
+ (AirOSDeviceConnectionError, "cannot_connect"),
+ (AirOSKeyDataMissingError, "key_data_missing"),
+ (Exception, "unknown"),
+ ],
+ ids=[
+ "invalid_auth",
+ "cannot_connect",
+ "key_data_missing",
+ "unknown",
+ ],
+)
+async def test_reauth_flow_scenarios(
+ hass: HomeAssistant,
+ mock_airos_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ reauth_exception: Exception,
+ expected_error: str,
+) -> None:
+ """Test reauthentication from start (failure) to finish (success)."""
+ mock_config_entry.add_to_hass(hass)
+
+ mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+
+ flow = flows[0]
+ assert flow["step_id"] == REAUTH_STEP
+
+ mock_airos_client.login.side_effect = reauth_exception
+
+ result = await hass.config_entries.flow.async_configure(
+ flow["flow_id"],
+ user_input={CONF_PASSWORD: NEW_PASSWORD},
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == REAUTH_STEP
+ assert result["errors"] == {"base": expected_error}
+
+ mock_airos_client.login.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ flow["flow_id"],
+ user_input={CONF_PASSWORD: NEW_PASSWORD},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+
+ updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
+ assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
+
+
+async def test_reauth_unique_id_mismatch(
+ hass: HomeAssistant,
+ mock_airos_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reauthentication failure when the unique ID changes."""
+ mock_config_entry.add_to_hass(hass)
+
+ mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ flows = hass.config_entries.flow.async_progress()
+ flow = flows[0]
+
+ mock_airos_client.login.side_effect = None
+ mock_airos_client.status.return_value.derived.mac = "FF:23:45:67:89:AB"
+
+ result = await hass.config_entries.flow.async_configure(
+ flow["flow_id"],
+ user_input={CONF_PASSWORD: NEW_PASSWORD},
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "unique_id_mismatch"
+
+ updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
+ assert updated_entry.data[CONF_PASSWORD] != NEW_PASSWORD
diff --git a/tests/components/airos/test_diagnostics.py b/tests/components/airos/test_diagnostics.py
index 453e8ff1f03..b0e227dd112 100644
--- a/tests/components/airos/test_diagnostics.py
+++ b/tests/components/airos/test_diagnostics.py
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.airos.coordinator import AirOSData
+from homeassistant.components.airos.coordinator import AirOS8Data
from homeassistant.core import HomeAssistant
from . import setup_integration
@@ -19,7 +19,7 @@ async def test_diagnostics(
hass_client: ClientSessionGenerator,
mock_airos_client: MagicMock,
mock_config_entry: MockConfigEntry,
- ap_fixture: AirOSData,
+ ap_fixture: AirOS8Data,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py
new file mode 100644
index 00000000000..30e2498d7d7
--- /dev/null
+++ b/tests/components/airos/test_init.py
@@ -0,0 +1,169 @@
+"""Test for airOS integration setup."""
+
+from __future__ import annotations
+
+from unittest.mock import ANY, MagicMock
+
+from homeassistant.components.airos.const import (
+ DEFAULT_SSL,
+ DEFAULT_VERIFY_SSL,
+ DOMAIN,
+ SECTION_ADVANCED_SETTINGS,
+)
+from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+MOCK_CONFIG_V1 = {
+ CONF_HOST: "1.1.1.1",
+ CONF_USERNAME: "ubnt",
+ CONF_PASSWORD: "test-password",
+}
+
+MOCK_CONFIG_PLAIN = {
+ CONF_HOST: "1.1.1.1",
+ CONF_USERNAME: "ubnt",
+ CONF_PASSWORD: "test-password",
+ SECTION_ADVANCED_SETTINGS: {
+ CONF_SSL: False,
+ CONF_VERIFY_SSL: False,
+ },
+}
+
+MOCK_CONFIG_V1_2 = {
+ CONF_HOST: "1.1.1.1",
+ CONF_USERNAME: "ubnt",
+ CONF_PASSWORD: "test-password",
+ SECTION_ADVANCED_SETTINGS: {
+ CONF_SSL: DEFAULT_SSL,
+ CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
+ },
+}
+
+
+async def test_setup_entry_with_default_ssl(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_airos_client: MagicMock,
+ mock_airos_class: MagicMock,
+) -> None:
+ """Test setting up a config entry with default SSL options."""
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ mock_airos_class.assert_called_once_with(
+ host=mock_config_entry.data[CONF_HOST],
+ username=mock_config_entry.data[CONF_USERNAME],
+ password=mock_config_entry.data[CONF_PASSWORD],
+ session=ANY,
+ use_ssl=DEFAULT_SSL,
+ )
+
+ assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
+ assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
+
+
+async def test_setup_entry_without_ssl(
+ hass: HomeAssistant,
+ mock_airos_client: MagicMock,
+ mock_airos_class: MagicMock,
+) -> None:
+ """Test setting up a config entry adjusted to plain HTTP."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=MOCK_CONFIG_PLAIN,
+ entry_id="1",
+ unique_id="airos_device",
+ version=1,
+ minor_version=2,
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.LOADED
+
+ mock_airos_class.assert_called_once_with(
+ host=entry.data[CONF_HOST],
+ username=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ session=ANY,
+ use_ssl=False,
+ )
+
+ assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False
+ assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
+
+
+async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None:
+ """Test migrate entry unique id."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data=MOCK_CONFIG_V1,
+ entry_id="1",
+ unique_id="airos_device",
+ version=1,
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.LOADED
+ assert entry.version == 1
+ assert entry.minor_version == 2
+ assert entry.data == MOCK_CONFIG_V1_2
+
+
+async def test_migrate_future_return(
+ hass: HomeAssistant,
+ mock_airos_client: MagicMock,
+) -> None:
+ """Test migrate entry unique id."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data=MOCK_CONFIG_V1_2,
+ entry_id="1",
+ unique_id="airos_device",
+ version=2,
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state is ConfigEntryState.MIGRATION_ERROR
+
+
+async def test_load_unload_entry(
+ hass: HomeAssistant,
+ mock_airos_client: MagicMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setup and unload config entry."""
+ mock_config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py
index 7f39f504753..2e30a181905 100644
--- a/tests/components/airos/test_sensor.py
+++ b/tests/components/airos/test_sensor.py
@@ -3,11 +3,7 @@
from datetime import timedelta
from unittest.mock import AsyncMock
-from airos.exceptions import (
- AirOSConnectionAuthenticationError,
- AirOSDataMissingError,
- AirOSDeviceConnectionError,
-)
+from airos.exceptions import AirOSDataMissingError, AirOSDeviceConnectionError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -39,7 +35,6 @@ async def test_all_entities(
@pytest.mark.parametrize(
("exception"),
[
- AirOSConnectionAuthenticationError,
TimeoutError,
AirOSDeviceConnectionError,
AirOSDataMissingError,
diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py
index 2adc5498e7b..a65c51b3fd6 100644
--- a/tests/components/airthings_ble/test_config_flow.py
+++ b/tests/components/airthings_ble/test_config_flow.py
@@ -47,7 +47,7 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
assert result["description_placeholders"] == {
- "name": "Airthings Wave Plus (123456)"
+ "name": "Airthings Wave Plus (2930123456)"
}
with patch_async_setup_entry():
@@ -56,7 +56,7 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == "Airthings Wave Plus (123456)"
+ assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
@@ -136,7 +136,7 @@ async def test_user_setup(hass: HomeAssistant) -> None:
schema = result["data_schema"].schema
assert schema.get(CONF_ADDRESS).container == {
- "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus"
+ "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus (2930123456)"
}
with patch(
@@ -149,7 +149,7 @@ async def test_user_setup(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == "Airthings Wave Plus (123456)"
+ assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
@@ -186,7 +186,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
schema = result["data_schema"].schema
assert schema.get(CONF_ADDRESS).container == {
- "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus"
+ "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus (2930123456)"
}
with patch(
@@ -199,7 +199,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == "Airthings Wave Plus (123456)"
+ assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
@@ -267,7 +267,7 @@ async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "cannot_connect"
+ assert result["reason"] == "no_devices_found"
async def test_unsupported_device(hass: HomeAssistant) -> None:
@@ -281,3 +281,72 @@ async def test_unsupported_device(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
+
+
+async def test_bluetooth_confirm_firmware_required(hass: HomeAssistant) -> None:
+ """Test discovery via bluetooth with a valid device."""
+ device = AirthingsDevice(
+ manufacturer="Airthings AS",
+ model=AirthingsDeviceType.WAVE_ENHANCE_EU,
+ name="Airthings Wave Enhance",
+ identifier="123456",
+ )
+ device.firmware.update_current_version("1.0.0")
+ device.firmware.update_required_version("2.6.1")
+ with (
+ patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
+ patch_airthings_ble(device),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_BLUETOOTH},
+ data=WAVE_SERVICE_INFO,
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "bluetooth_confirm"
+
+ with patch_async_setup_entry():
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"not": "empty"}
+ )
+ await hass.async_block_till_done()
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "firmware_upgrade_required"
+
+
+async def test_step_user_firmware_required(hass: HomeAssistant) -> None:
+ """Test the user has selected a device with a firmware upgrade required."""
+ device = AirthingsDevice(
+ manufacturer="Airthings AS",
+ model=AirthingsDeviceType.WAVE_ENHANCE_EU,
+ name="Airthings Wave Enhance",
+ identifier="123456",
+ )
+ device.firmware.update_current_version("1.0.0")
+ device.firmware.update_required_version("2.6.1")
+
+ with (
+ patch(
+ "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
+ return_value=[WAVE_SERVICE_INFO],
+ ),
+ patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
+ patch_airthings_ble(device),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with patch(
+ "homeassistant.components.airthings_ble.async_setup_entry",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "firmware_upgrade_required"
diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr
index 09dea8c354c..0895073e0aa 100644
--- a/tests/components/airzone/snapshots/test_diagnostics.ambr
+++ b/tests/components/airzone/snapshots/test_diagnostics.ambr
@@ -340,6 +340,7 @@
5,
]),
'problems': False,
+ 'q-adapt': 0,
}),
'2': dict({
'available': True,
diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py
index 55cb32b67a5..7c35b39010a 100644
--- a/tests/components/airzone/util.py
+++ b/tests/components/airzone/util.py
@@ -38,6 +38,7 @@ from aioairzone.const import (
API_NAME,
API_ON,
API_POWER,
+ API_Q_ADAPT,
API_ROOM_TEMP,
API_SET_POINT,
API_SLEEP,
@@ -353,6 +354,7 @@ HVAC_SYSTEMS_MOCK = {
API_POWER: 0,
API_SYSTEM_FIRMWARE: "3.31",
API_SYSTEM_TYPE: 1,
+ API_Q_ADAPT: 0,
}
]
}
diff --git a/tests/components/airzone_cloud/conftest.py b/tests/components/airzone_cloud/conftest.py
index 10388eb63d3..5a94a292c49 100644
--- a/tests/components/airzone_cloud/conftest.py
+++ b/tests/components/airzone_cloud/conftest.py
@@ -9,7 +9,7 @@ import pytest
class MockAirzoneCloudApi(AirzoneCloudApi):
"""Mock AirzoneCloudApi class."""
- async def mock_update(self: "AirzoneCloudApi"):
+ async def mock_update(self):
"""Mock AirzoneCloudApi _update function."""
await self.update_polling()
diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py
new file mode 100644
index 00000000000..bd6f58c98b7
--- /dev/null
+++ b/tests/components/aladdin_connect/conftest.py
@@ -0,0 +1,48 @@
+"""Fixtures for aladdin_connect tests."""
+
+import pytest
+
+from homeassistant.components.aladdin_connect import DOMAIN
+from homeassistant.components.application_credentials import (
+ ClientCredential,
+ async_import_client_credential,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from .const import CLIENT_ID, CLIENT_SECRET, USER_ID
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(autouse=True)
+async def setup_credentials(hass: HomeAssistant) -> None:
+ """Fixture to setup credentials."""
+ assert await async_setup_component(hass, "application_credentials", {})
+ await async_import_client_credential(
+ hass,
+ DOMAIN,
+ ClientCredential(CLIENT_ID, CLIENT_SECRET),
+ )
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+ """Define a mock config entry fixture."""
+ return MockConfigEntry(
+ version=1,
+ minor_version=1,
+ domain=DOMAIN,
+ title="Aladdin Connect",
+ data={
+ "auth_implementation": DOMAIN,
+ "token": {
+ "access_token": "old-token",
+ "refresh_token": "old-refresh-token",
+ "expires_in": 3600,
+ "expires_at": 1234567890,
+ },
+ },
+ source="user",
+ unique_id=USER_ID,
+ )
diff --git a/tests/components/aladdin_connect/const.py b/tests/components/aladdin_connect/const.py
new file mode 100644
index 00000000000..b431557c454
--- /dev/null
+++ b/tests/components/aladdin_connect/const.py
@@ -0,0 +1,5 @@
+"""Constants for aladdin_connect tests."""
+
+CLIENT_ID = "1234"
+CLIENT_SECRET = "5678"
+USER_ID = "test_user_123"
diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py
new file mode 100644
index 00000000000..ee555cf2ebb
--- /dev/null
+++ b/tests/components/aladdin_connect/test_config_flow.py
@@ -0,0 +1,414 @@
+"""Test the Aladdin Connect Garage Door config flow."""
+
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.aladdin_connect.const import (
+ DOMAIN,
+ OAUTH2_AUTHORIZE,
+ OAUTH2_TOKEN,
+)
+from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+
+from .const import CLIENT_ID, USER_ID
+
+from tests.common import MockConfigEntry
+from tests.test_util.aiohttp import AiohttpClientMocker
+from tests.typing import ClientSessionGenerator
+
+
+@pytest.fixture
+def use_cloud(hass: HomeAssistant) -> None:
+ """Set up the cloud component."""
+ hass.config.components.add("cloud")
+
+
+@pytest.fixture
+async def access_token(hass: HomeAssistant) -> str:
+ """Return a valid access token with sub field for unique ID."""
+ return config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "sub": USER_ID,
+ "aud": [],
+ "iat": 1234567890,
+ "exp": 1234567890 + 3600,
+ },
+ )
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_full_flow(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ access_token,
+) -> None:
+ """Check full flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ state = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "flow_id": result["flow_id"],
+ "redirect_uri": "https://example.com/auth/external/callback",
+ },
+ )
+
+ assert result["url"] == (
+ f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}"
+ )
+
+ client = await hass_client_no_auth()
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+ assert resp.headers["content-type"] == "text/html; charset=utf-8"
+
+ aioclient_mock.post(
+ OAUTH2_TOKEN,
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": access_token,
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch(
+ "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "Aladdin Connect"
+ assert result["data"] == {
+ "auth_implementation": DOMAIN,
+ "token": {
+ "access_token": access_token,
+ "refresh_token": "mock-refresh-token",
+ "expires_in": 60,
+ "expires_at": result["data"]["token"]["expires_at"],
+ "type": "Bearer",
+ },
+ }
+ assert result["result"].unique_id == USER_ID
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_full_dhcp_flow(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ access_token,
+) -> None:
+ """Check full flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_DHCP},
+ data=DhcpServiceInfo(
+ ip="192.168.0.123", macaddress="001122334455", hostname="gdocntl-334455"
+ ),
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "oauth_discovery"
+ assert not result["errors"]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+ state = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "flow_id": result["flow_id"],
+ "redirect_uri": "https://example.com/auth/external/callback",
+ },
+ )
+
+ assert result["url"] == (
+ f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}"
+ )
+
+ client = await hass_client_no_auth()
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+ assert resp.headers["content-type"] == "text/html; charset=utf-8"
+
+ aioclient_mock.post(
+ OAUTH2_TOKEN,
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": access_token,
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch(
+ "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "Aladdin Connect"
+ assert result["data"] == {
+ "auth_implementation": DOMAIN,
+ "token": {
+ "access_token": access_token,
+ "refresh_token": "mock-refresh-token",
+ "expires_in": 60,
+ "expires_at": result["data"]["token"]["expires_at"],
+ "type": "Bearer",
+ },
+ }
+ assert result["result"].unique_id == USER_ID
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_duplicate_entry(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ access_token,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Check full flow."""
+ mock_config_entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ state = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "flow_id": result["flow_id"],
+ "redirect_uri": "https://example.com/auth/external/callback",
+ },
+ )
+
+ assert result["url"] == (
+ f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}"
+ )
+
+ client = await hass_client_no_auth()
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+ assert resp.headers["content-type"] == "text/html; charset=utf-8"
+
+ aioclient_mock.post(
+ OAUTH2_TOKEN,
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": access_token,
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch(
+ "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_duplicate_dhcp_entry(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ access_token,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Check full flow."""
+ mock_config_entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_DHCP},
+ data=DhcpServiceInfo(
+ ip="192.168.0.123", macaddress="001122334455", hostname="gdocntl-334455"
+ ),
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_flow_reauth(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ access_token,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reauth flow."""
+ mock_config_entry.add_to_hass(hass)
+
+ # Start reauth flow
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ # Should show reauth confirm form
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ # Confirm reauth
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ # Should now go to user step (OAuth)
+ state = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "flow_id": result["flow_id"],
+ "redirect_uri": "https://example.com/auth/external/callback",
+ },
+ )
+
+ assert result["url"] == (
+ f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}"
+ )
+
+ client = await hass_client_no_auth()
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+
+ aioclient_mock.post(
+ OAUTH2_TOKEN,
+ json={
+ "refresh_token": "new-refresh-token",
+ "access_token": access_token,
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch(
+ "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ # Verify the entry was updated, not a new one created
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+@pytest.mark.usefixtures("current_request_with_host", "use_cloud")
+async def test_flow_wrong_account_reauth(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test reauth flow with wrong account."""
+ mock_config_entry.add_to_hass(hass)
+
+ # Start reauth flow
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ # Should show reauth confirm form
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ # Create access token for a different user
+ different_user_token = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "sub": "different_user_456",
+ "aud": [],
+ "iat": 1234567890,
+ "exp": 1234567890 + 3600,
+ },
+ )
+
+ # Start reauth flow
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ # Confirm reauth
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ # Complete OAuth with different user
+ state = config_entry_oauth2_flow._encode_jwt(
+ hass,
+ {
+ "flow_id": result["flow_id"],
+ "redirect_uri": "https://example.com/auth/external/callback",
+ },
+ )
+
+ client = await hass_client_no_auth()
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+
+ aioclient_mock.post(
+ OAUTH2_TOKEN,
+ json={
+ "refresh_token": "wrong-user-refresh-token",
+ "access_token": different_user_token,
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ # Should abort with wrong account
+ assert result["type"] == "abort"
+ assert result["reason"] == "wrong_account"
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+async def test_no_cloud(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Check we abort when cloud is not enabled."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "cloud_not_enabled"
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+async def test_reauthentication_no_cloud(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test Aladdin Connect reauthentication without cloud."""
+ mock_config_entry.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "cloud_not_enabled"
diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py
index b2ef0a722fd..bc147839c2f 100644
--- a/tests/components/aladdin_connect/test_init.py
+++ b/tests/components/aladdin_connect/test_init.py
@@ -1,79 +1,116 @@
"""Tests for the Aladdin Connect integration."""
-from homeassistant.components.aladdin_connect import DOMAIN
-from homeassistant.config_entries import (
- SOURCE_IGNORE,
- ConfigEntryDisabler,
- ConfigEntryState,
-)
+from unittest.mock import AsyncMock, patch
+
+from homeassistant.components.aladdin_connect.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
from tests.common import MockConfigEntry
-async def test_aladdin_connect_repair_issue(
- hass: HomeAssistant, issue_registry: ir.IssueRegistry
-) -> None:
- """Test the Aladdin Connect configuration entry loading/unloading handles the repair."""
- config_entry_1 = MockConfigEntry(
- title="Example 1",
+async def test_setup_entry(hass: HomeAssistant) -> None:
+ """Test a successful setup entry."""
+ config_entry = MockConfigEntry(
domain=DOMAIN,
+ data={
+ "token": {
+ "access_token": "test_token",
+ "refresh_token": "test_refresh_token",
+ }
+ },
+ unique_id="test_unique_id",
)
- config_entry_1.add_to_hass(hass)
- await hass.config_entries.async_setup(config_entry_1.entry_id)
- await hass.async_block_till_done()
- assert config_entry_1.state is ConfigEntryState.LOADED
+ config_entry.add_to_hass(hass)
- # Add a second one
- config_entry_2 = MockConfigEntry(
- title="Example 2",
+ mock_door = AsyncMock()
+ mock_door.device_id = "test_device_id"
+ mock_door.door_number = 1
+ mock_door.name = "Test Door"
+ mock_door.status = "closed"
+ mock_door.link_status = "connected"
+ mock_door.battery_level = 100
+ mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}"
+
+ mock_client = AsyncMock()
+ mock_client.get_doors.return_value = [mock_door]
+
+ with (
+ patch(
+ "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation",
+ return_value=AsyncMock(),
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session",
+ return_value=AsyncMock(),
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.AladdinConnectClient",
+ return_value=mock_client,
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth",
+ return_value=AsyncMock(),
+ ),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+
+async def test_unload_entry(hass: HomeAssistant) -> None:
+ """Test a successful unload entry."""
+ config_entry = MockConfigEntry(
domain=DOMAIN,
+ data={
+ "token": {
+ "access_token": "test_token",
+ "refresh_token": "test_refresh_token",
+ }
+ },
+ unique_id="test_unique_id",
)
- config_entry_2.add_to_hass(hass)
- await hass.config_entries.async_setup(config_entry_2.entry_id)
+ config_entry.add_to_hass(hass)
+
+ # Mock door data
+ mock_door = AsyncMock()
+ mock_door.device_id = "test_device_id"
+ mock_door.door_number = 1
+ mock_door.name = "Test Door"
+ mock_door.status = "closed"
+ mock_door.link_status = "connected"
+ mock_door.battery_level = 100
+ mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}"
+
+ # Mock client
+ mock_client = AsyncMock()
+ mock_client.get_doors.return_value = [mock_door]
+
+ with (
+ patch(
+ "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation",
+ return_value=AsyncMock(),
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session",
+ return_value=AsyncMock(),
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.AladdinConnectClient",
+ return_value=mock_client,
+ ),
+ patch(
+ "homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth",
+ return_value=AsyncMock(),
+ ),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
- assert config_entry_2.state is ConfigEntryState.LOADED
- assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
-
- # Add an ignored entry
- config_entry_3 = MockConfigEntry(
- source=SOURCE_IGNORE,
- domain=DOMAIN,
- )
- config_entry_3.add_to_hass(hass)
- await hass.config_entries.async_setup(config_entry_3.entry_id)
- await hass.async_block_till_done()
-
- assert config_entry_3.state is ConfigEntryState.NOT_LOADED
-
- # Add a disabled entry
- config_entry_4 = MockConfigEntry(
- disabled_by=ConfigEntryDisabler.USER,
- domain=DOMAIN,
- )
- config_entry_4.add_to_hass(hass)
- await hass.config_entries.async_setup(config_entry_4.entry_id)
- await hass.async_block_till_done()
-
- assert config_entry_4.state is ConfigEntryState.NOT_LOADED
-
- # Remove the first one
- await hass.config_entries.async_remove(config_entry_1.entry_id)
- await hass.async_block_till_done()
-
- assert config_entry_1.state is ConfigEntryState.NOT_LOADED
- assert config_entry_2.state is ConfigEntryState.LOADED
- assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
-
- # Remove the second one
- await hass.config_entries.async_remove(config_entry_2.entry_id)
- await hass.async_block_till_done()
-
- assert config_entry_1.state is ConfigEntryState.NOT_LOADED
- assert config_entry_2.state is ConfigEntryState.NOT_LOADED
- assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None
-
- # Check the ignored and disabled entries are removed
- assert not hass.config_entries.async_entries(DOMAIN)
+ assert config_entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py
index b10a93df0c9..d83956ee128 100644
--- a/tests/components/alexa/test_capabilities.py
+++ b/tests/components/alexa/test_capabilities.py
@@ -383,7 +383,7 @@ async def test_api_remote_set_power_state(
},
)
- _, msg = await assert_request_calls_service(
+ _, _msg = await assert_request_calls_service(
"Alexa.PowerController",
target_name,
"remote#test",
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index e4a46db7d34..5c9555b6581 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -1815,7 +1815,7 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None:
# Test for media_position error.
with pytest.raises(AssertionError):
- _, msg = await assert_request_calls_service(
+ _, _msg = await assert_request_calls_service(
"Alexa.SeekController",
"AdjustSeekPosition",
"media_player#test_seek",
@@ -2374,7 +2374,7 @@ async def test_cover_position_range(
"range": {"minimumValue": 1, "maximumValue": 100},
} in position_state_mappings
- call, msg = await assert_request_calls_service(
+ _call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_range",
diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py
index 3c68b7b7626..bed7abc3e33 100644
--- a/tests/components/alexa_devices/conftest.py
+++ b/tests/components/alexa_devices/conftest.py
@@ -1,16 +1,20 @@
"""Alexa Devices tests configuration."""
from collections.abc import Generator
+from copy import deepcopy
from unittest.mock import AsyncMock, patch
-from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor
from aioamazondevices.const import DEVICE_TYPE_TO_MODEL
import pytest
-from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN
+from homeassistant.components.alexa_devices.const import (
+ CONF_LOGIN_DATA,
+ CONF_SITE,
+ DOMAIN,
+)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME
+from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -41,30 +45,10 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.login_mode_interactive.return_value = {
"customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
}
client.get_devices_data.return_value = {
- TEST_SERIAL_NUMBER: AmazonDevice(
- account_name="Echo Test",
- capabilities=["AUDIO_PLAYER", "MICROPHONE"],
- device_family="mine",
- device_type="echo",
- device_owner_customer_id="amazon_ower_id",
- device_cluster_members=[TEST_SERIAL_NUMBER],
- device_locale="en-US",
- online=True,
- serial_number=TEST_SERIAL_NUMBER,
- software_version="echo_test_software_version",
- do_not_disturb=False,
- response_style=None,
- bluetooth_state=True,
- entity_id="11111111-2222-3333-4444-555555555555",
- appliance_id="G1234567890123456789012345678A",
- sensors={
- "temperature": AmazonDeviceSensor(
- name="temperature", value="22.5", scale="CELSIUS"
- )
- },
- )
+ TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1)
}
client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get(
device.device_type
@@ -82,7 +66,12 @@ def mock_config_entry() -> MockConfigEntry:
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
- CONF_LOGIN_DATA: {"session": "test-session"},
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ CONF_SITE: "https://www.amazon.com",
+ },
},
unique_id=TEST_USERNAME,
+ version=1,
+ minor_version=3,
)
diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py
index 6a4dff1c38d..8fe407bd1c7 100644
--- a/tests/components/alexa_devices/const.py
+++ b/tests/components/alexa_devices/const.py
@@ -1,9 +1,52 @@
"""Alexa Devices tests const."""
+from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor
+
TEST_CODE = "023123"
-TEST_COUNTRY = "IT"
TEST_PASSWORD = "fake_password"
-TEST_SERIAL_NUMBER = "echo_test_serial_number"
TEST_USERNAME = "fake_email@gmail.com"
-TEST_DEVICE_ID = "echo_test_device_id"
+TEST_DEVICE_1_SN = "echo_test_serial_number"
+TEST_DEVICE_1_ID = "echo_test_device_id"
+TEST_DEVICE_1 = AmazonDevice(
+ account_name="Echo Test",
+ capabilities=["AUDIO_PLAYER", "MICROPHONE"],
+ device_family="mine",
+ device_type="echo",
+ household_device=False,
+ device_owner_customer_id="amazon_ower_id",
+ device_cluster_members=[TEST_DEVICE_1_SN],
+ online=True,
+ serial_number=TEST_DEVICE_1_SN,
+ software_version="echo_test_software_version",
+ entity_id="11111111-2222-3333-4444-555555555555",
+ endpoint_id="G1234567890123456789012345678A",
+ sensors={
+ "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
+ "temperature": AmazonDeviceSensor(
+ name="temperature", value="22.5", error=False, scale="CELSIUS"
+ ),
+ },
+)
+
+TEST_DEVICE_2_SN = "echo_test_2_serial_number"
+TEST_DEVICE_2_ID = "echo_test_2_device_id"
+TEST_DEVICE_2 = AmazonDevice(
+ account_name="Echo Test 2",
+ capabilities=["AUDIO_PLAYER", "MICROPHONE"],
+ device_family="mine",
+ device_type="echo",
+ household_device=True,
+ device_owner_customer_id="amazon_ower_id",
+ device_cluster_members=[TEST_DEVICE_2_SN],
+ online=True,
+ serial_number=TEST_DEVICE_2_SN,
+ software_version="echo_test_2_software_version",
+ entity_id="11111111-2222-3333-4444-555555555555",
+ endpoint_id="G1234567890123456789012345678A",
+ sensors={
+ "temperature": AmazonDeviceSensor(
+ name="temperature", value="22.5", error=False, scale="CELSIUS"
+ )
+ },
+)
diff --git a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
index 16f9eeaedae..c6b9a2afa08 100644
--- a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
+++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr
@@ -1,52 +1,4 @@
# serializer version: 1
-# name: test_all_entities[binary_sensor.echo_test_bluetooth-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': None,
- 'config_entry_id': ,
- 'config_subentry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'binary_sensor',
- 'entity_category': ,
- 'entity_id': 'binary_sensor.echo_test_bluetooth',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': None,
- 'original_icon': None,
- 'original_name': 'Bluetooth',
- 'platform': 'alexa_devices',
- 'previous_unique_id': None,
- 'suggested_object_id': None,
- 'supported_features': 0,
- 'translation_key': 'bluetooth',
- 'unique_id': 'echo_test_serial_number-bluetooth',
- 'unit_of_measurement': None,
- })
-# ---
-# name: test_all_entities[binary_sensor.echo_test_bluetooth-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'friendly_name': 'Echo Test Bluetooth',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.echo_test_bluetooth',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
# name: test_all_entities[binary_sensor.echo_test_connectivity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
index 0f3c3647e90..2450d9e7d7b 100644
--- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
+++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr
@@ -2,7 +2,6 @@
# name: test_device_diagnostics
dict({
'account name': 'Echo Test',
- 'bluetooth state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -12,9 +11,17 @@
]),
'device family': 'mine',
'device type': 'echo',
- 'do not disturb': False,
'online': True,
- 'response style': None,
+ 'sensors': dict({
+ 'dnd': dict({
+ '__type': "",
+ 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
+ }),
+ 'temperature': dict({
+ '__type': "",
+ 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
+ }),
+ }),
'serial number': 'echo_test_serial_number',
'software version': 'echo_test_software_version',
})
@@ -25,7 +32,6 @@
'devices': list([
dict({
'account name': 'Echo Test',
- 'bluetooth state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -35,9 +41,17 @@
]),
'device family': 'mine',
'device type': 'echo',
- 'do not disturb': False,
'online': True,
- 'response style': None,
+ 'sensors': dict({
+ 'dnd': dict({
+ '__type': "",
+ 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
+ }),
+ 'temperature': dict({
+ '__type': "",
+ 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
+ }),
+ }),
'serial number': 'echo_test_serial_number',
'software version': 'echo_test_software_version',
}),
@@ -49,6 +63,7 @@
'data': dict({
'login_data': dict({
'session': 'test-session',
+ 'site': 'https://www.amazon.com',
}),
'password': '**REDACTED**',
'username': '**REDACTED**',
@@ -57,7 +72,7 @@
'discovery_keys': dict({
}),
'domain': 'alexa_devices',
- 'minor_version': 1,
+ 'minor_version': 3,
'options': dict({
}),
'pref_disable_new_entities': False,
diff --git a/tests/components/alexa_devices/snapshots/test_sensor.ambr b/tests/components/alexa_devices/snapshots/test_sensor.ambr
index ae245b5c463..64611933100 100644
--- a/tests/components/alexa_devices/snapshots/test_sensor.ambr
+++ b/tests/components/alexa_devices/snapshots/test_sensor.ambr
@@ -4,7 +4,9 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
'config_entry_id': ,
'config_subentry_id': ,
'device_class': None,
@@ -42,6 +44,7 @@
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Echo Test Temperature',
+ 'state_class': ,
'unit_of_measurement': ,
}),
'context': ,
diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr
index b95108b0d03..2f6576adb35 100644
--- a/tests/components/alexa_devices/snapshots/test_services.ambr
+++ b/tests/components/alexa_devices/snapshots/test_services.ambr
@@ -4,8 +4,6 @@
tuple(
dict({
'account_name': 'Echo Test',
- 'appliance_id': 'G1234567890123456789012345678A',
- 'bluetooth_state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -14,15 +12,21 @@
'echo_test_serial_number',
]),
'device_family': 'mine',
- 'device_locale': 'en-US',
'device_owner_customer_id': 'amazon_ower_id',
'device_type': 'echo',
- 'do_not_disturb': False,
+ 'endpoint_id': 'G1234567890123456789012345678A',
'entity_id': '11111111-2222-3333-4444-555555555555',
+ 'household_device': False,
'online': True,
- 'response_style': None,
'sensors': dict({
+ 'dnd': dict({
+ 'error': False,
+ 'name': 'dnd',
+ 'scale': None,
+ 'value': False,
+ }),
'temperature': dict({
+ 'error': False,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',
@@ -31,7 +35,7 @@
'serial_number': 'echo_test_serial_number',
'software_version': 'echo_test_software_version',
}),
- 'chimes_bells_01',
+ 'bell_02',
),
dict({
}),
@@ -42,8 +46,6 @@
tuple(
dict({
'account_name': 'Echo Test',
- 'appliance_id': 'G1234567890123456789012345678A',
- 'bluetooth_state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -52,15 +54,21 @@
'echo_test_serial_number',
]),
'device_family': 'mine',
- 'device_locale': 'en-US',
'device_owner_customer_id': 'amazon_ower_id',
'device_type': 'echo',
- 'do_not_disturb': False,
+ 'endpoint_id': 'G1234567890123456789012345678A',
'entity_id': '11111111-2222-3333-4444-555555555555',
+ 'household_device': False,
'online': True,
- 'response_style': None,
'sensors': dict({
+ 'dnd': dict({
+ 'error': False,
+ 'name': 'dnd',
+ 'scale': None,
+ 'value': False,
+ }),
'temperature': dict({
+ 'error': False,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',
diff --git a/tests/components/alexa_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr
index c622cc67ea7..3ce484cf95b 100644
--- a/tests/components/alexa_devices/snapshots/test_switch.ambr
+++ b/tests/components/alexa_devices/snapshots/test_switch.ambr
@@ -30,7 +30,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb',
- 'unique_id': 'echo_test_serial_number-do_not_disturb',
+ 'unique_id': 'echo_test_serial_number-dnd',
'unit_of_measurement': None,
})
# ---
diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py
index a2e38b3459b..6b55a701b45 100644
--- a/tests/components/alexa_devices/test_binary_sensor.py
+++ b/tests/components/alexa_devices/test_binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from .const import TEST_SERIAL_NUMBER
+from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -83,7 +83,7 @@ async def test_offline_device(
entity_id = "binary_sensor.echo_test_connectivity"
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = False
await setup_integration(hass, mock_config_entry)
@@ -92,7 +92,7 @@ async def test_offline_device(
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = True
freezer.tick(SCAN_INTERVAL)
@@ -101,3 +101,41 @@ async def test_offline_device(
assert (state := hass.states.get(entity_id))
assert state.state != STATE_UNAVAILABLE
+
+
+async def test_dynamic_device(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test device added dynamically."""
+
+ entity_id_1 = "binary_sensor.echo_test_connectivity"
+ entity_id_2 = "binary_sensor.echo_test_2_connectivity"
+
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: TEST_DEVICE_1,
+ }
+
+ await setup_integration(hass, mock_config_entry)
+
+ assert (state := hass.states.get(entity_id_1))
+ assert state.state == STATE_ON
+
+ assert not hass.states.get(entity_id_2)
+
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: TEST_DEVICE_1,
+ TEST_DEVICE_2_SN: TEST_DEVICE_2,
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get(entity_id_1))
+ assert state.state == STATE_ON
+
+ assert (state := hass.states.get(entity_id_2))
+ assert state.state == STATE_ON
diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py
index 9aea6fe4c44..4722f9c0c5f 100644
--- a/tests/components/alexa_devices/test_config_flow.py
+++ b/tests/components/alexa_devices/test_config_flow.py
@@ -9,7 +9,11 @@ from aioamazondevices.exceptions import (
)
import pytest
-from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN
+from homeassistant.components.alexa_devices.const import (
+ CONF_LOGIN_DATA,
+ CONF_SITE,
+ DOMAIN,
+)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -48,6 +52,7 @@ async def test_full_flow(
CONF_PASSWORD: TEST_PASSWORD,
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
},
}
assert result["result"].unique_id == TEST_USERNAME
@@ -158,6 +163,16 @@ async def test_reauth_successful(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
+ assert mock_config_entry.data == {
+ CONF_CODE: "000000",
+ CONF_USERNAME: TEST_USERNAME,
+ CONF_PASSWORD: "other_fake_password",
+ CONF_LOGIN_DATA: {
+ "customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
+ },
+ }
+
@pytest.mark.parametrize(
("side_effect", "error"),
@@ -206,8 +221,15 @@ async def test_reauth_not_successful(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
- assert mock_config_entry.data[CONF_PASSWORD] == "fake_password"
- assert mock_config_entry.data[CONF_CODE] == "111111"
+ assert mock_config_entry.data == {
+ CONF_CODE: "111111",
+ CONF_USERNAME: TEST_USERNAME,
+ CONF_PASSWORD: "fake_password",
+ CONF_LOGIN_DATA: {
+ "customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
+ },
+ }
async def test_reconfigure_successful(
@@ -240,7 +262,14 @@ async def test_reconfigure_successful(
assert reconfigure_result["reason"] == "reconfigure_successful"
# changed entry
- assert mock_config_entry.data[CONF_PASSWORD] == new_password
+ assert mock_config_entry.data == {
+ CONF_USERNAME: TEST_USERNAME,
+ CONF_PASSWORD: new_password,
+ CONF_LOGIN_DATA: {
+ "customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
+ },
+ }
@pytest.mark.parametrize(
@@ -297,5 +326,6 @@ async def test_reconfigure_fails(
CONF_PASSWORD: TEST_PASSWORD,
CONF_LOGIN_DATA: {
"customer_info": {"user_id": TEST_USERNAME},
+ CONF_SITE: "https://www.amazon.com",
},
}
diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py
new file mode 100644
index 00000000000..3e0880fcd07
--- /dev/null
+++ b/tests/components/alexa_devices/test_coordinator.py
@@ -0,0 +1,52 @@
+"""Tests for the Alexa Devices coordinator."""
+
+from unittest.mock import AsyncMock
+
+from freezegun.api import FrozenDateTimeFactory
+
+from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
+from homeassistant.const import STATE_ON
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_coordinator_stale_device(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test coordinator data update removes stale Alexa devices."""
+
+ entity_id_0 = "binary_sensor.echo_test_connectivity"
+ entity_id_1 = "binary_sensor.echo_test_2_connectivity"
+
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: TEST_DEVICE_1,
+ TEST_DEVICE_2_SN: TEST_DEVICE_2,
+ }
+
+ await setup_integration(hass, mock_config_entry)
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == STATE_ON
+ assert (state := hass.states.get(entity_id_1))
+ assert state.state == STATE_ON
+
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: TEST_DEVICE_1,
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == STATE_ON
+
+ # Entity is removed
+ assert not hass.states.get(entity_id_1)
diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py
index 3c18d432543..6c7a6ef4a81 100644
--- a/tests/components/alexa_devices/test_diagnostics.py
+++ b/tests/components/alexa_devices/test_diagnostics.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
-from .const import TEST_SERIAL_NUMBER
+from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry
from tests.components.diagnostics import (
@@ -54,9 +54,7 @@ async def test_device_diagnostics(
"""Test Amazon device diagnostics."""
await setup_integration(hass, mock_config_entry)
- device = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
- )
+ device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_1_SN)})
assert device, repr(device_registry.devices)
assert await get_diagnostics_for_device(
diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py
index c628a5e00e7..0b20b1fe239 100644
--- a/tests/components/alexa_devices/test_init.py
+++ b/tests/components/alexa_devices/test_init.py
@@ -2,16 +2,21 @@
from unittest.mock import AsyncMock
+import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN
+from homeassistant.components.alexa_devices.const import (
+ CONF_LOGIN_DATA,
+ CONF_SITE,
+ DOMAIN,
+)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
-from .const import TEST_COUNTRY, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME
+from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -26,30 +31,87 @@ async def test_device_info(
"""Test device registry integration."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry is not None
assert device_entry == snapshot
+@pytest.mark.parametrize(
+ ("minor_version", "extra_data"),
+ [
+ # Standard migration case
+ (
+ 1,
+ {
+ CONF_COUNTRY: "US",
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ },
+ },
+ ),
+ # Edge case #1: no country, site already in login data, minor version 1
+ (
+ 1,
+ {
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ CONF_SITE: "https://www.amazon.com",
+ },
+ },
+ ),
+ # Edge case #2: no country, site in data (wrong place), minor version 1
+ (
+ 1,
+ {
+ CONF_SITE: "https://www.amazon.com",
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ },
+ },
+ ),
+ # Edge case #3: no country, site already in login data, minor version 2
+ (
+ 2,
+ {
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ CONF_SITE: "https://www.amazon.com",
+ },
+ },
+ ),
+ # Edge case #4: no country, site in data (wrong place), minor version 2
+ (
+ 2,
+ {
+ CONF_SITE: "https://www.amazon.com",
+ CONF_LOGIN_DATA: {
+ "session": "test-session",
+ },
+ },
+ ),
+ ],
+)
async def test_migrate_entry(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
+ minor_version: int,
+ extra_data: dict[str, str],
) -> None:
"""Test successful migration of entry data."""
+
config_entry = MockConfigEntry(
domain=DOMAIN,
title="Amazon Test Account",
data={
- CONF_COUNTRY: TEST_COUNTRY,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
- CONF_LOGIN_DATA: {"session": "test-session"},
+ **(extra_data),
},
unique_id=TEST_USERNAME,
version=1,
- minor_version=0,
+ minor_version=minor_version,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
@@ -57,5 +119,5 @@ async def test_migrate_entry(
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert config_entry.state is ConfigEntryState.LOADED
- assert config_entry.minor_version == 1
- assert config_entry.data["site"] == f"https://www.amazon.{TEST_COUNTRY}"
+ assert config_entry.minor_version == 3
+ assert config_entry.data[CONF_LOGIN_DATA][CONF_SITE] == "https://www.amazon.com"
diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py
index 6067874e370..eafea4b525c 100644
--- a/tests/components/alexa_devices/test_notify.py
+++ b/tests/components/alexa_devices/test_notify.py
@@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import setup_integration
-from .const import TEST_SERIAL_NUMBER
+from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -83,7 +83,7 @@ async def test_offline_device(
entity_id = "notify.echo_test_announce"
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = False
await setup_integration(hass, mock_config_entry)
@@ -92,7 +92,7 @@ async def test_offline_device(
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = True
freezer.tick(SCAN_INTERVAL)
diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py
index e8875fe08a4..3bb1b3f0a0d 100644
--- a/tests/components/alexa_devices/test_sensor.py
+++ b/tests/components/alexa_devices/test_sensor.py
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from .const import TEST_SERIAL_NUMBER
+from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -83,7 +83,7 @@ async def test_offline_device(
entity_id = "sensor.echo_test_temperature"
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = False
await setup_integration(hass, mock_config_entry)
@@ -92,7 +92,7 @@ async def test_offline_device(
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = True
freezer.tick(SCAN_INTERVAL)
@@ -133,11 +133,39 @@ async def test_unit_of_measurement(
entity_id = f"sensor.echo_test_{sensor}"
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
- ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)}
+ TEST_DEVICE_1_SN
+ ].sensors = {
+ sensor: AmazonDeviceSensor(
+ name=sensor, value=api_value, error=False, scale=scale
+ )
+ }
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert state.state == state_value
assert state.attributes["unit_of_measurement"] == unit
+
+
+async def test_sensor_unavailable(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test sensor is unavailable."""
+
+ entity_id = "sensor.echo_test_illuminance"
+
+ mock_amazon_devices_client.get_devices_data.return_value[
+ TEST_DEVICE_1_SN
+ ].sensors = {
+ "illuminance": AmazonDeviceSensor(
+ name="illuminance", value="800", error=True, scale=None
+ )
+ }
+
+ await setup_integration(hass, mock_config_entry)
+
+ assert (state := hass.states.get(entity_id))
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py
index 914664199c2..9ea1a271a7f 100644
--- a/tests/components/alexa_devices/test_services.py
+++ b/tests/components/alexa_devices/test_services.py
@@ -8,7 +8,6 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.const import DOMAIN
from homeassistant.components.alexa_devices.services import (
ATTR_SOUND,
- ATTR_SOUND_VARIANT,
ATTR_TEXT_COMMAND,
SERVICE_SOUND_NOTIFICATION,
SERVICE_TEXT_COMMAND,
@@ -20,7 +19,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from . import setup_integration
-from .const import TEST_DEVICE_ID, TEST_SERIAL_NUMBER
+from .const import TEST_DEVICE_1_ID, TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, mock_device_registry
@@ -50,7 +49,7 @@ async def test_send_sound_service(
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry
@@ -58,8 +57,7 @@ async def test_send_sound_service(
DOMAIN,
SERVICE_SOUND_NOTIFICATION,
{
- ATTR_SOUND: "chimes_bells",
- ATTR_SOUND_VARIANT: 1,
+ ATTR_SOUND: "bell_02",
ATTR_DEVICE_ID: device_entry.id,
},
blocking=True,
@@ -81,7 +79,7 @@ async def test_send_text_service(
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry
@@ -103,18 +101,17 @@ async def test_send_text_service(
("sound", "device_id", "translation_key", "translation_placeholders"),
[
(
- "chimes_bells",
+ "bell_02",
"fake_device_id",
"invalid_device_id",
{"device_id": "fake_device_id"},
),
(
"wrong_sound_name",
- TEST_DEVICE_ID,
+ TEST_DEVICE_1_ID,
"invalid_sound_value",
{
"sound": "wrong_sound_name",
- "variant": "1",
},
),
],
@@ -131,7 +128,7 @@ async def test_invalid_parameters(
"""Test invalid service parameters."""
device_entry = dr.DeviceEntry(
- id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
+ id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
mock_device_registry(
hass,
@@ -146,7 +143,6 @@ async def test_invalid_parameters(
SERVICE_SOUND_NOTIFICATION,
{
ATTR_SOUND: sound,
- ATTR_SOUND_VARIANT: 1,
ATTR_DEVICE_ID: device_id,
},
blocking=True,
@@ -168,7 +164,7 @@ async def test_config_entry_not_loaded(
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, TEST_SERIAL_NUMBER)}
+ identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry
@@ -183,8 +179,7 @@ async def test_config_entry_not_loaded(
DOMAIN,
SERVICE_SOUND_NOTIFICATION,
{
- ATTR_SOUND: "chimes_bells",
- ATTR_SOUND_VARIANT: 1,
+ ATTR_SOUND: "bell_02",
ATTR_DEVICE_ID: device_entry.id,
},
blocking=True,
diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py
index 26a18fb731a..6bbc1f68d02 100644
--- a/tests/components/alexa_devices/test_switch.py
+++ b/tests/components/alexa_devices/test_switch.py
@@ -1,7 +1,9 @@
"""Tests for the Alexa Devices switch platform."""
+from copy import deepcopy
from unittest.mock import AsyncMock, patch
+from aioamazondevices.api import AmazonDeviceSensor
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -23,10 +25,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from .conftest import TEST_SERIAL_NUMBER
+from .conftest import TEST_DEVICE_1, TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+ENTITY_ID = "switch.echo_test_do_not_disturb"
+
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
@@ -52,48 +56,59 @@ async def test_switch_dnd(
"""Test switching DND."""
await setup_integration(hass, mock_config_entry)
- entity_id = "switch.echo_test_do_not_disturb"
-
- assert (state := hass.states.get(entity_id))
+ assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OFF
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: entity_id},
+ {ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1
- mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
- ].do_not_disturb = True
+ device_data = deepcopy(TEST_DEVICE_1)
+ device_data.sensors = {
+ "dnd": AmazonDeviceSensor(name="dnd", value=True, error=False, scale=None),
+ "temperature": AmazonDeviceSensor(
+ name="temperature", value="22.5", error=False, scale="CELSIUS"
+ ),
+ }
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: device_data
+ }
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
- assert (state := hass.states.get(entity_id))
+ assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_ON
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: entity_id},
+ {ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
- mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
- ].do_not_disturb = False
+ device_data.sensors = {
+ "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
+ "temperature": AmazonDeviceSensor(
+ name="temperature", value="22.5", error=False, scale="CELSIUS"
+ ),
+ }
+ mock_amazon_devices_client.get_devices_data.return_value = {
+ TEST_DEVICE_1_SN: device_data
+ }
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2
- assert (state := hass.states.get(entity_id))
+ assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OFF
@@ -104,25 +119,22 @@ async def test_offline_device(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test offline device handling."""
-
- entity_id = "switch.echo_test_do_not_disturb"
-
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = False
await setup_integration(hass, mock_config_entry)
- assert (state := hass.states.get(entity_id))
+ assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
- TEST_SERIAL_NUMBER
+ TEST_DEVICE_1_SN
].online = True
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
- assert (state := hass.states.get(entity_id))
+ assert (state := hass.states.get(ENTITY_ID))
assert state.state != STATE_UNAVAILABLE
diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py
index 1cf190bd297..020971d8f76 100644
--- a/tests/components/alexa_devices/test_utils.py
+++ b/tests/components/alexa_devices/test_utils.py
@@ -10,8 +10,10 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TUR
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
+from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry
@@ -54,3 +56,41 @@ async def test_alexa_api_call_exceptions(
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == key
assert exc_info.value.translation_placeholders == {"error": error}
+
+
+async def test_alexa_unique_id_migration(
+ hass: HomeAssistant,
+ mock_amazon_devices_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test unique_id migration."""
+
+ mock_config_entry.add_to_hass(hass)
+
+ device = device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={(DOMAIN, mock_config_entry.entry_id)},
+ name=mock_config_entry.title,
+ manufacturer="Amazon",
+ model="Echo Dot",
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+
+ entity = entity_registry.async_get_or_create(
+ SWITCH_DOMAIN,
+ DOMAIN,
+ unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
+ device_id=device.id,
+ config_entry=mock_config_entry,
+ has_entity_name=True,
+ )
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ migrated_entity = entity_registry.async_get(entity.entity_id)
+ assert migrated_entity is not None
+ assert migrated_entity.config_entry_id == mock_config_entry.entry_id
+ assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd"
diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py
index 51579177e7e..ec35cc56d51 100644
--- a/tests/components/analytics/test_analytics.py
+++ b/tests/components/analytics/test_analytics.py
@@ -13,6 +13,10 @@ from syrupy.matchers import path_type
from homeassistant.components.analytics.analytics import (
Analytics,
+ AnalyticsInput,
+ AnalyticsModifications,
+ DeviceAnalyticsModifications,
+ EntityAnalyticsModifications,
async_devices_payload,
)
from homeassistant.components.analytics.const import (
@@ -23,14 +27,17 @@ from homeassistant.components.analytics.const import (
ATTR_STATISTICS,
ATTR_USAGE,
)
+from homeassistant.components.number import NumberDeviceClass
+from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
+from homeassistant.const import ATTR_ASSUMED_STATE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.loader import IntegrationNotFound
from homeassistant.setup import async_setup_component
-from tests.common import MockConfigEntry, MockModule, mock_integration
+from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -976,25 +983,22 @@ async def test_submitting_legacy_integrations(
@pytest.mark.usefixtures("enable_custom_integrations")
-async def test_devices_payload(
+async def test_devices_payload_no_entities(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
- """Test devices payload."""
+ """Test devices payload with no entities."""
assert await async_setup_component(hass, "analytics", {})
assert await async_devices_payload(hass) == {
"version": "home-assistant:1",
"home_assistant": MOCK_VERSION,
- "devices": [],
+ "integrations": {},
}
mock_config_entry = MockConfigEntry(domain="hue")
mock_config_entry.add_to_hass(hass)
- mock_custom_config_entry = MockConfigEntry(domain="test")
- mock_custom_config_entry.add_to_hass(hass)
-
# Normal device with all fields
device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
@@ -1019,10 +1023,8 @@ async def test_devices_payload(
)
# Device without model_id
- no_model_id_config_entry = MockConfigEntry(domain="no_model_id")
- no_model_id_config_entry.add_to_hass(hass)
device_registry.async_get_or_create(
- config_entry_id=no_model_id_config_entry.entry_id,
+ config_entry_id=mock_config_entry.entry_id,
identifiers={("device", "4")},
manufacturer="test-manufacturer",
)
@@ -1044,6 +1046,8 @@ async def test_devices_payload(
)
# Device from custom integration
+ mock_custom_config_entry = MockConfigEntry(domain="test")
+ mock_custom_config_entry.add_to_hass(hass)
device_registry.async_get_or_create(
config_entry_id=mock_custom_config_entry.entry_id,
identifiers={("device", "7")},
@@ -1051,86 +1055,374 @@ async def test_devices_payload(
model_id="test-model-id7",
)
- assert await async_devices_payload(hass) == {
- "version": "home-assistant:1",
- "home_assistant": MOCK_VERSION,
- "devices": [
- {
- "manufacturer": "test-manufacturer",
- "model_id": "test-model-id",
- "model": "test-model",
- "sw_version": "test-sw-version",
- "hw_version": "test-hw-version",
- "integration": "hue",
- "is_custom_integration": False,
- "has_configuration_url": True,
- "via_device": None,
- "entry_type": None,
- },
- {
- "manufacturer": "test-manufacturer",
- "model_id": "test-model-id",
- "model": None,
- "sw_version": None,
- "hw_version": None,
- "integration": "hue",
- "is_custom_integration": False,
- "has_configuration_url": False,
- "via_device": None,
- "entry_type": "service",
- },
- {
- "manufacturer": "test-manufacturer",
- "model_id": None,
- "model": None,
- "sw_version": None,
- "hw_version": None,
- "integration": "no_model_id",
- "has_configuration_url": False,
- "via_device": None,
- "entry_type": None,
- },
- {
- "manufacturer": None,
- "model_id": "test-model-id",
- "model": None,
- "sw_version": None,
- "hw_version": None,
- "integration": "hue",
- "is_custom_integration": False,
- "has_configuration_url": False,
- "via_device": None,
- "entry_type": None,
- },
- {
- "manufacturer": "test-manufacturer6",
- "model_id": "test-model-id6",
- "model": None,
- "sw_version": None,
- "hw_version": None,
- "integration": "hue",
- "is_custom_integration": False,
- "has_configuration_url": False,
- "via_device": 0,
- "entry_type": None,
- },
- {
- "entry_type": None,
- "has_configuration_url": False,
- "hw_version": None,
- "integration": "test",
- "manufacturer": "test-manufacturer7",
- "model": None,
- "model_id": "test-model-id7",
- "sw_version": None,
- "via_device": None,
- "is_custom_integration": True,
- "custom_integration_version": "1.2.3",
- },
- ],
- }
+ # Device from an integration with a service type
+ mock_service_config_entry = MockConfigEntry(domain="uptime")
+ mock_service_config_entry.add_to_hass(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=mock_service_config_entry.entry_id,
+ identifiers={("device", "8")},
+ manufacturer="test-manufacturer8",
+ model_id="test-model-id8",
+ )
client = await hass_client()
response = await client.get("/api/analytics/devices")
assert response.status == HTTPStatus.OK
- assert await response.json() == await async_devices_payload(hass)
+ assert await response.json() == {
+ "version": "home-assistant:1",
+ "home_assistant": MOCK_VERSION,
+ "integrations": {
+ "hue": {
+ "devices": [
+ {
+ "entry_type": None,
+ "has_configuration_url": True,
+ "hw_version": "test-hw-version",
+ "manufacturer": "test-manufacturer",
+ "model": "test-model",
+ "model_id": "test-model-id",
+ "sw_version": "test-sw-version",
+ "via_device": None,
+ "entities": [],
+ },
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": "test-manufacturer",
+ "model": None,
+ "model_id": None,
+ "sw_version": None,
+ "via_device": None,
+ "entities": [],
+ },
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": None,
+ "model": None,
+ "model_id": "test-model-id",
+ "sw_version": None,
+ "via_device": None,
+ "entities": [],
+ },
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": "test-manufacturer6",
+ "model": None,
+ "model_id": "test-model-id6",
+ "sw_version": None,
+ "via_device": ["hue", 0],
+ "entities": [],
+ },
+ ],
+ "entities": [],
+ },
+ },
+ }
+
+
+async def test_devices_payload_with_entities(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test devices payload with entities."""
+ assert await async_setup_component(hass, "analytics", {})
+
+ mock_config_entry = MockConfigEntry(domain="hue")
+ mock_config_entry.add_to_hass(hass)
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={("device", "1")},
+ manufacturer="test-manufacturer",
+ model_id="test-model-id",
+ )
+ device_entry_2 = device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={("device", "2")},
+ manufacturer="test-manufacturer",
+ model_id="test-model-id",
+ )
+ device_entry_3 = device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={("device", "3")},
+ manufacturer="test-manufacturer",
+ model_id="test-model-id",
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+
+ # First device
+
+ # Entity with capabilities
+ entity_registry.async_get_or_create(
+ domain="light",
+ platform="hue",
+ unique_id="1",
+ capabilities={"min_color_temp_kelvin": 2000, "max_color_temp_kelvin": 6535},
+ device_id=device_entry.id,
+ has_entity_name=True,
+ )
+ # Entity with category and device class
+ entity_registry.async_get_or_create(
+ domain="number",
+ platform="hue",
+ unique_id="1",
+ device_id=device_entry.id,
+ entity_category=EntityCategory.CONFIG,
+ has_entity_name=True,
+ original_device_class=NumberDeviceClass.TEMPERATURE,
+ )
+ hass.states.async_set("number.hue_1", "2")
+ # Entity with assumed state
+ entity_registry.async_get_or_create(
+ domain="light",
+ platform="hue",
+ unique_id="2",
+ device_id=device_entry.id,
+ has_entity_name=True,
+ )
+ hass.states.async_set("light.hue_2", "on", {ATTR_ASSUMED_STATE: True})
+ # Entity from a different integration
+ entity_registry.async_get_or_create(
+ domain="light",
+ platform="shelly",
+ unique_id="1",
+ device_id=device_entry.id,
+ has_entity_name=True,
+ )
+
+ # Second device
+ entity_registry.async_get_or_create(
+ domain="light",
+ platform="hue",
+ unique_id="3",
+ device_id=device_entry_2.id,
+ )
+
+ # Third device (service type)
+ entity_registry.async_get_or_create(
+ domain="light",
+ platform="hue",
+ unique_id="4",
+ device_id=device_entry_3.id,
+ )
+
+ # Entity without device with unit of measurement and state class
+ entity_registry.async_get_or_create(
+ domain="sensor",
+ platform="hue",
+ unique_id="1",
+ capabilities={"state_class": "measurement"},
+ original_device_class=SensorDeviceClass.TEMPERATURE,
+ unit_of_measurement="°C",
+ )
+
+ client = await hass_client()
+ response = await client.get("/api/analytics/devices")
+ assert response.status == HTTPStatus.OK
+ assert await response.json() == {
+ "version": "home-assistant:1",
+ "home_assistant": MOCK_VERSION,
+ "integrations": {
+ "hue": {
+ "devices": [
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": "test-manufacturer",
+ "model": None,
+ "model_id": "test-model-id",
+ "sw_version": None,
+ "via_device": None,
+ "entities": [
+ {
+ "assumed_state": None,
+ "domain": "light",
+ "entity_category": None,
+ "has_entity_name": True,
+ "original_device_class": None,
+ "unit_of_measurement": None,
+ },
+ {
+ "assumed_state": False,
+ "domain": "number",
+ "entity_category": "config",
+ "has_entity_name": True,
+ "original_device_class": "temperature",
+ "unit_of_measurement": None,
+ },
+ {
+ "assumed_state": True,
+ "domain": "light",
+ "entity_category": None,
+ "has_entity_name": True,
+ "original_device_class": None,
+ "unit_of_measurement": None,
+ },
+ ],
+ },
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": "test-manufacturer",
+ "model": None,
+ "model_id": "test-model-id",
+ "sw_version": None,
+ "via_device": None,
+ "entities": [
+ {
+ "assumed_state": None,
+ "domain": "light",
+ "entity_category": None,
+ "has_entity_name": False,
+ "original_device_class": None,
+ "unit_of_measurement": None,
+ },
+ ],
+ },
+ ],
+ "entities": [
+ {
+ "assumed_state": None,
+ "domain": "sensor",
+ "entity_category": None,
+ "has_entity_name": False,
+ "original_device_class": "temperature",
+ "unit_of_measurement": "°C",
+ },
+ ],
+ },
+ "shelly": {
+ "devices": [],
+ "entities": [
+ {
+ "assumed_state": None,
+ "domain": "light",
+ "entity_category": None,
+ "has_entity_name": True,
+ "original_device_class": None,
+ "unit_of_measurement": None,
+ },
+ ],
+ },
+ },
+ }
+
+
+async def test_analytics_platforms(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ device_registry: dr.DeviceRegistry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test analytics platforms."""
+ assert await async_setup_component(hass, "analytics", {})
+
+ mock_config_entry = MockConfigEntry(domain="test")
+ mock_config_entry.add_to_hass(hass)
+
+ device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={("device", "1")},
+ manufacturer="test-manufacturer",
+ model_id="test-model-id",
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=mock_config_entry.entry_id,
+ identifiers={("device", "2")},
+ manufacturer="test-manufacturer",
+ model_id="test-model-id-2",
+ )
+
+ entity_registry.async_get_or_create(
+ domain="sensor",
+ platform="test",
+ unique_id="1",
+ capabilities={"options": ["secret1", "secret2"]},
+ )
+ entity_registry.async_get_or_create(
+ domain="sensor",
+ platform="test",
+ unique_id="2",
+ capabilities={"options": ["secret1", "secret2"]},
+ )
+
+ async def async_modify_analytics(
+ hass: HomeAssistant,
+ analytics_input: AnalyticsInput,
+ ) -> AnalyticsModifications:
+ first = True
+ devices_configs = {}
+ for device_id in analytics_input.device_ids:
+ device_config = DeviceAnalyticsModifications()
+ devices_configs[device_id] = device_config
+ if first:
+ first = False
+ else:
+ device_config.remove = True
+
+ first = True
+ entities_configs = {}
+ for entity_id in analytics_input.entity_ids:
+ entity_entry = entity_registry.async_get(entity_id)
+ entity_config = EntityAnalyticsModifications()
+ entities_configs[entity_id] = entity_config
+ if first:
+ first = False
+ entity_config.capabilities = dict(entity_entry.capabilities)
+ entity_config.capabilities["options"] = len(
+ entity_config.capabilities["options"]
+ )
+ else:
+ entity_config.remove = True
+
+ return AnalyticsModifications(
+ devices=devices_configs,
+ entities=entities_configs,
+ )
+
+ platform_mock = Mock(async_modify_analytics=async_modify_analytics)
+ mock_platform(hass, "test.analytics", platform_mock)
+
+ client = await hass_client()
+ response = await client.get("/api/analytics/devices")
+ assert response.status == HTTPStatus.OK
+ assert await response.json() == {
+ "version": "home-assistant:1",
+ "home_assistant": MOCK_VERSION,
+ "integrations": {
+ "test": {
+ "devices": [
+ {
+ "entry_type": None,
+ "has_configuration_url": False,
+ "hw_version": None,
+ "manufacturer": "test-manufacturer",
+ "model": None,
+ "model_id": "test-model-id",
+ "sw_version": None,
+ "via_device": None,
+ "entities": [],
+ },
+ ],
+ "entities": [
+ {
+ "assumed_state": None,
+ "domain": "sensor",
+ "entity_category": None,
+ "has_entity_name": False,
+ "original_device_class": None,
+ "unit_of_measurement": None,
+ },
+ ],
+ },
+ },
+ }
diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json
index ff1baca49ed..f939d28da4f 100644
--- a/tests/components/analytics_insights/fixtures/current_data.json
+++ b/tests/components/analytics_insights/fixtures/current_data.json
@@ -799,7 +799,6 @@
"geofency": 313,
"hvv_departures": 70,
"devolo_home_control": 65,
- "vulcan": 24,
"laundrify": 151,
"openhome": 730,
"rainmachine": 381,
diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py
index efc05772a9a..2588f61177f 100644
--- a/tests/components/androidtv/test_media_player.py
+++ b/tests/components/androidtv/test_media_player.py
@@ -21,7 +21,7 @@ from homeassistant.components.androidtv.const import (
DEFAULT_PORT,
DOMAIN,
)
-from homeassistant.components.androidtv.media_player import (
+from homeassistant.components.androidtv.services import (
ATTR_DEVICE_PATH,
ATTR_LOCAL_PATH,
SERVICE_ADB_COMMAND,
diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py
index 9652ac0c3a9..86ae7ab6739 100644
--- a/tests/components/androidtv_remote/test_config_flow.py
+++ b/tests/components/androidtv_remote/test_config_flow.py
@@ -102,6 +102,10 @@ async def test_user_flow_cannot_connect(
assert not result["errors"]
host = "1.2.3.4"
+ name = "My Android TV"
+ mac = "1A:2B:3C:4D:5E:6F"
+ unique_id = "1a:2b:3c:4d:5e:6f"
+ pin = "123456"
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect())
@@ -119,9 +123,87 @@ async def test_user_flow_cannot_connect(
mock_api.async_get_name_and_mac.assert_called()
mock_api.async_start_pairing.assert_not_called()
+ # End in CREATE_ENTRY to test that its able to recover
+ mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
+ mock_api.async_start_pairing = AsyncMock(return_value=None)
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": host}
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "pair"
+ assert "pin" in result["data_schema"].schema
+ assert not result["errors"]
+
+ mock_api.async_finish_pairing = AsyncMock(return_value=None)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"pin": pin}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == name
+ assert result["data"] == {"host": host, "name": name, "mac": mac}
+ assert result["context"]["unique_id"] == unique_id
+
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
- assert len(mock_setup_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_user_flow_start_pair_cannot_connect(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_unload_entry: AsyncMock,
+ mock_api: MagicMock,
+) -> None:
+ """Test async_start_pairing raises CannotConnect in the user flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert "host" in result["data_schema"].schema
+ assert not result["errors"]
+
+ host = "1.2.3.4"
+ name = "My Android TV"
+ mac = "1A:2B:3C:4D:5E:6F"
+
+ mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
+ mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
+ mock_api.async_start_pairing = AsyncMock(side_effect=CannotConnect())
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": host}
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert "host" in result["data_schema"].schema
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ mock_api.async_generate_cert_if_missing.assert_called()
+ mock_api.async_get_name_and_mac.assert_called()
+ mock_api.async_start_pairing.assert_called()
+
+ pin = "123456"
+ mock_api.async_start_pairing = AsyncMock(return_value=None)
+ mock_api.async_finish_pairing = AsyncMock(return_value=None)
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": host}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "pair"
+ assert not result["errors"]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"pin": pin}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_pairing_invalid_auth(
@@ -146,6 +228,7 @@ async def test_user_flow_pairing_invalid_auth(
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
+ unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
@@ -164,7 +247,7 @@ async def test_user_flow_pairing_invalid_auth(
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
- mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth())
+ mock_api.async_finish_pairing = AsyncMock(side_effect=[InvalidAuth(), None])
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
@@ -181,9 +264,19 @@ async def test_user_flow_pairing_invalid_auth(
assert mock_api.async_start_pairing.call_count == 1
assert mock_api.async_finish_pairing.call_count == 1
+ # End in CREATE_ENTRY to test that its able to recover
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"pin": pin}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == name
+ assert result["data"] == {"host": host, "name": name, "mac": mac}
+ assert result["context"]["unique_id"] == unique_id
+
+ assert mock_api.async_finish_pairing.call_count == 2
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
- assert len(mock_setup_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_pairing_connection_closed(
@@ -208,6 +301,7 @@ async def test_user_flow_pairing_connection_closed(
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
+ unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
mock_api.async_get_name_and_mac = AsyncMock(return_value=(name, mac))
@@ -243,9 +337,19 @@ async def test_user_flow_pairing_connection_closed(
assert mock_api.async_start_pairing.call_count == 2
assert mock_api.async_finish_pairing.call_count == 1
+ # End in CREATE_ENTRY to test that its able to recover
+ mock_api.async_finish_pairing = AsyncMock(return_value=None)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"pin": pin}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == name
+ assert result["data"] == {"host": host, "name": name, "mac": mac}
+ assert result["context"]["unique_id"] == unique_id
+
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
- assert len(mock_setup_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_pairing_connection_closed_followed_by_cannot_connect(
@@ -332,10 +436,9 @@ async def test_user_flow_already_configured_host_changed_reloads_entry(
"mac": mac,
},
unique_id=unique_id,
- state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
- hass.config.components.add(DOMAIN)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -361,8 +464,8 @@ async def test_user_flow_already_configured_host_changed_reloads_entry(
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
- assert hass.config_entries.async_entries(DOMAIN)[0].data == {
+ assert len(mock_setup_entry.mock_calls) == 2
+ assert mock_config_entry.data == {
"host": host,
"name": name_existing,
"mac": mac,
@@ -568,6 +671,7 @@ async def test_zeroconf_flow_pairing_invalid_auth(
host = "1.2.3.4"
name = "My Android TV"
mac = "1A:2B:3C:4D:5E:6F"
+ unique_id = "1a:2b:3c:4d:5e:6f"
pin = "123456"
result = await hass.config_entries.flow.async_init(
@@ -602,7 +706,7 @@ async def test_zeroconf_flow_pairing_invalid_auth(
mock_api.async_generate_cert_if_missing.assert_called()
mock_api.async_start_pairing.assert_called()
- mock_api.async_finish_pairing = AsyncMock(side_effect=InvalidAuth())
+ mock_api.async_finish_pairing = AsyncMock(side_effect=[InvalidAuth(), None])
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": pin}
@@ -619,9 +723,18 @@ async def test_zeroconf_flow_pairing_invalid_auth(
assert mock_api.async_start_pairing.call_count == 1
assert mock_api.async_finish_pairing.call_count == 1
+ # End in CREATE_ENTRY to test that its able to recover
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"pin": pin}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == name
+ assert result["data"] == {"host": host, "name": name, "mac": mac}
+ assert result["context"]["unique_id"] == unique_id
+
await hass.async_block_till_done()
assert len(mock_unload_entry.mock_calls) == 0
- assert len(mock_setup_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_flow_already_configured_host_changed_reloads_entry(
@@ -649,10 +762,10 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry(
"mac": mac,
},
unique_id=unique_id,
- state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
- hass.config.components.add(DOMAIN)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -671,13 +784,13 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry(
assert result["reason"] == "already_configured"
await hass.async_block_till_done()
- assert hass.config_entries.async_entries(DOMAIN)[0].data == {
+ assert mock_config_entry.data == {
"host": host,
"name": name,
"mac": mac,
}
assert len(mock_unload_entry.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 2
async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry(
@@ -832,10 +945,10 @@ async def test_reauth_flow_success(
"mac": mac,
},
unique_id=unique_id,
- state=ConfigEntryState.LOADED,
)
mock_config_entry.add_to_hass(hass)
- hass.config.components.add(DOMAIN)
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
mock_config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
@@ -878,7 +991,7 @@ async def test_reauth_flow_success(
"mac": mac,
}
assert len(mock_unload_entry.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 2
async def test_reauth_flow_cannot_connect(
@@ -1123,7 +1236,12 @@ async def test_reconfigure_flow_cannot_connect(
assert result["step_id"] == "reconfigure"
mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True)
- mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect())
+ mock_api.async_get_name_and_mac = AsyncMock(
+ side_effect=[
+ CannotConnect(),
+ (mock_config_entry.data["name"], mock_config_entry.data["mac"]),
+ ]
+ )
new_host = "4.3.2.1"
result = await hass.config_entries.flow.async_configure(
@@ -1136,6 +1254,16 @@ async def test_reconfigure_flow_cannot_connect(
assert mock_config_entry.data["host"] == "1.2.3.4"
assert len(mock_setup_entry.mock_calls) == 0
+ # End in CREATE_ENTRY to test that its able to recover
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": new_host}
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reconfigure_successful"
+ assert mock_config_entry.data["host"] == new_host
+ assert len(mock_setup_entry.mock_calls) == 1
+
async def test_reconfigure_flow_unique_id_mismatch(
hass: HomeAssistant,
diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py
index 2af8aeb2f56..ba885759979 100644
--- a/tests/components/androidtv_remote/test_media_player.py
+++ b/tests/components/androidtv_remote/test_media_player.py
@@ -291,7 +291,7 @@ async def test_media_player_play_media(
)
mock_api.send_launch_app_command.assert_called_with("tv.twitch.android.app")
- with pytest.raises(ValueError):
+ with pytest.raises(HomeAssistantError, match="Channel must be numeric: abc"):
await hass.services.async_call(
"media_player",
"play_media",
@@ -303,7 +303,7 @@ async def test_media_player_play_media(
blocking=True,
)
- with pytest.raises(ValueError):
+ with pytest.raises(HomeAssistantError, match="Invalid media type: music"):
await hass.services.async_call(
"media_player",
"play_media",
diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py
index ff54539bb39..a97a3b7a378 100644
--- a/tests/components/anthropic/test_init.py
+++ b/tests/components/anthropic/test_init.py
@@ -156,6 +156,8 @@ async def test_migration_from_v1_to_v2(
@pytest.mark.parametrize(
(
"config_entry_disabled_by",
+ "device_disabled_by",
+ "entity_disabled_by",
"merged_config_entry_disabled_by",
"conversation_subentry_data",
"main_config_entry",
@@ -163,6 +165,8 @@ async def test_migration_from_v1_to_v2(
[
(
[ConfigEntryDisabler.USER, None],
+ [DeviceEntryDisabler.CONFIG_ENTRY, None],
+ [RegistryEntryDisabler.CONFIG_ENTRY, None],
None,
[
{
@@ -182,18 +186,20 @@ async def test_migration_from_v1_to_v2(
),
(
[None, ConfigEntryDisabler.USER],
+ [None, DeviceEntryDisabler.CONFIG_ENTRY],
+ [None, RegistryEntryDisabler.CONFIG_ENTRY],
None,
[
{
"conversation_entity_id": "conversation.claude",
- "device_disabled_by": DeviceEntryDisabler.USER,
- "entity_disabled_by": RegistryEntryDisabler.DEVICE,
+ "device_disabled_by": None,
+ "entity_disabled_by": None,
"device": 0,
},
{
"conversation_entity_id": "conversation.claude_2",
- "device_disabled_by": None,
- "entity_disabled_by": None,
+ "device_disabled_by": DeviceEntryDisabler.USER,
+ "entity_disabled_by": RegistryEntryDisabler.DEVICE,
"device": 1,
},
],
@@ -201,6 +207,8 @@ async def test_migration_from_v1_to_v2(
),
(
[ConfigEntryDisabler.USER, ConfigEntryDisabler.USER],
+ [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY],
+ [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY],
ConfigEntryDisabler.USER,
[
{
@@ -211,8 +219,8 @@ async def test_migration_from_v1_to_v2(
},
{
"conversation_entity_id": "conversation.claude_2",
- "device_disabled_by": None,
- "entity_disabled_by": None,
+ "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY,
+ "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY,
"device": 1,
},
],
@@ -225,6 +233,8 @@ async def test_migration_from_v1_disabled(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry_disabled_by: list[ConfigEntryDisabler | None],
+ device_disabled_by: list[DeviceEntryDisabler | None],
+ entity_disabled_by: list[RegistryEntryDisabler | None],
merged_config_entry_disabled_by: ConfigEntryDisabler | None,
conversation_subentry_data: list[dict[str, Any]],
main_config_entry: int,
@@ -264,7 +274,7 @@ async def test_migration_from_v1_disabled(
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
- disabled_by=DeviceEntryDisabler.CONFIG_ENTRY,
+ disabled_by=device_disabled_by[0],
)
entity_registry.async_get_or_create(
"conversation",
@@ -273,7 +283,7 @@ async def test_migration_from_v1_disabled(
config_entry=mock_config_entry,
device_id=device_1.id,
suggested_object_id="claude",
- disabled_by=RegistryEntryDisabler.CONFIG_ENTRY,
+ disabled_by=entity_disabled_by[0],
)
device_2 = device_registry.async_get_or_create(
@@ -283,6 +293,7 @@ async def test_migration_from_v1_disabled(
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
+ disabled_by=device_disabled_by[1],
)
entity_registry.async_get_or_create(
"conversation",
@@ -291,6 +302,7 @@ async def test_migration_from_v1_disabled(
config_entry=mock_config_entry_2,
device_id=device_2.id,
suggested_object_id="claude",
+ disabled_by=entity_disabled_by[1],
)
devices = [device_1, device_2]
diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py
index 564a986c126..c11d13ff8cd 100644
--- a/tests/components/aosmith/conftest.py
+++ b/tests/components/aosmith/conftest.py
@@ -29,7 +29,11 @@ FIXTURE_USER_INPUT = {
def build_device_fixture(
- heat_pump: bool, mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool
+ heat_pump: bool,
+ mode_pending: bool,
+ setpoint_pending: bool,
+ has_vacation_mode: bool,
+ supports_hot_water_plus: bool,
):
"""Build a fixture for a device."""
supported_modes: list[SupportedOperationModeInfo] = [
@@ -37,6 +41,7 @@ def build_device_fixture(
mode=OperationMode.ELECTRIC,
original_name="ELECTRIC",
has_day_selection=True,
+ supports_hot_water_plus=supports_hot_water_plus,
),
]
@@ -46,6 +51,7 @@ def build_device_fixture(
mode=OperationMode.HYBRID,
original_name="HYBRID",
has_day_selection=False,
+ supports_hot_water_plus=supports_hot_water_plus,
)
)
supported_modes.append(
@@ -53,6 +59,7 @@ def build_device_fixture(
mode=OperationMode.HEAT_PUMP,
original_name="HEAT_PUMP",
has_day_selection=False,
+ supports_hot_water_plus=supports_hot_water_plus,
)
)
@@ -62,20 +69,22 @@ def build_device_fixture(
mode=OperationMode.VACATION,
original_name="VACATION",
has_day_selection=True,
+ supports_hot_water_plus=False,
)
)
- device_type = (
- DeviceType.NEXT_GEN_HEAT_PUMP if heat_pump else DeviceType.RE3_CONNECTED
- )
-
current_mode = OperationMode.HEAT_PUMP if heat_pump else OperationMode.ELECTRIC
- model = "HPTS-50 200 202172000" if heat_pump else "EE12-50H55DVF 100,3806368"
+ if heat_pump and supports_hot_water_plus:
+ device_type = DeviceType.RE3_PREMIUM
+ elif heat_pump:
+ device_type = DeviceType.NEXT_GEN_HEAT_PUMP
+ else:
+ device_type = DeviceType.RE3_CONNECTED
return Device(
brand="aosmith",
- model=model,
+ model="Example model",
device_type=device_type,
dsn="dsn",
junction_id="junctionId",
@@ -83,6 +92,7 @@ def build_device_fixture(
serial="serial",
install_location="Basement",
supported_modes=supported_modes,
+ supports_hot_water_plus=supports_hot_water_plus,
status=DeviceStatus(
firmware_version="2.14",
is_online=True,
@@ -93,6 +103,7 @@ def build_device_fixture(
temperature_setpoint_previous=130,
temperature_setpoint_maximum=130,
hot_water_status=90,
+ hot_water_plus_level=1 if supports_hot_water_plus else None,
),
)
@@ -159,6 +170,12 @@ def get_devices_fixture_has_vacation_mode() -> bool:
return True
+@pytest.fixture
+def get_devices_fixture_supports_hot_water_plus() -> bool:
+ """Return whether to include hot water plus support in the get_devices fixture."""
+ return False
+
+
@pytest.fixture
async def mock_client(
hass: HomeAssistant,
@@ -166,6 +183,7 @@ async def mock_client(
get_devices_fixture_mode_pending: bool,
get_devices_fixture_setpoint_pending: bool,
get_devices_fixture_has_vacation_mode: bool,
+ get_devices_fixture_supports_hot_water_plus: bool,
) -> Generator[MagicMock]:
"""Return a mocked client."""
get_devices_fixture = [
@@ -174,6 +192,7 @@ async def mock_client(
mode_pending=get_devices_fixture_mode_pending,
setpoint_pending=get_devices_fixture_setpoint_pending,
has_vacation_mode=get_devices_fixture_has_vacation_mode,
+ supports_hot_water_plus=get_devices_fixture_supports_hot_water_plus,
)
]
get_all_device_info_fixture = await async_load_json_object_fixture(
diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr
index c4c1b0b1b93..057619a0246 100644
--- a/tests/components/aosmith/snapshots/test_device.ambr
+++ b/tests/components/aosmith/snapshots/test_device.ambr
@@ -20,7 +20,7 @@
'labels': set({
}),
'manufacturer': 'A. O. Smith',
- 'model': 'HPTS-50 200 202172000',
+ 'model': 'Example model',
'model_id': None,
'name': 'My water heater',
'name_by_user': None,
diff --git a/tests/components/aosmith/snapshots/test_select.ambr b/tests/components/aosmith/snapshots/test_select.ambr
new file mode 100644
index 00000000000..9e0c10319c3
--- /dev/null
+++ b/tests/components/aosmith/snapshots/test_select.ambr
@@ -0,0 +1,62 @@
+# serializer version: 1
+# name: test_state[True][select.my_water_heater_hot_water_plus_level-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'off',
+ 'level1',
+ 'level2',
+ 'level3',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'select',
+ 'entity_category': None,
+ 'entity_id': 'select.my_water_heater_hot_water_plus_level',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Hot Water+ level',
+ 'platform': 'aosmith',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'hot_water_plus_level',
+ 'unique_id': 'hot_water_plus_level_junctionId',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_state[True][select.my_water_heater_hot_water_plus_level-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'My water heater Hot Water+ level',
+ 'options': list([
+ 'off',
+ 'level1',
+ 'level2',
+ 'level3',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'select.my_water_heater_hot_water_plus_level',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'level1',
+ })
+# ---
diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py
index 940b0cbc6b5..975e6b2a061 100644
--- a/tests/components/aosmith/test_init.py
+++ b/tests/components/aosmith/test_init.py
@@ -56,6 +56,7 @@ async def test_config_entry_not_ready_get_energy_use_data_error(
mode_pending=False,
setpoint_pending=False,
has_vacation_mode=True,
+ supports_hot_water_plus=False,
)
]
diff --git a/tests/components/aosmith/test_select.py b/tests/components/aosmith/test_select.py
new file mode 100644
index 00000000000..75444b7d8c9
--- /dev/null
+++ b/tests/components/aosmith/test_select.py
@@ -0,0 +1,77 @@
+"""Tests for the select platform of the A. O. Smith integration."""
+
+from collections.abc import AsyncGenerator
+from unittest.mock import MagicMock, patch
+
+from py_aosmith.models import OperationMode
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.select import (
+ DOMAIN as SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.fixture(autouse=True)
+async def platforms() -> AsyncGenerator[None]:
+ """Return the platforms to be loaded for this test."""
+ with patch("homeassistant.components.aosmith.PLATFORMS", [Platform.SELECT]):
+ yield
+
+
+@pytest.mark.parametrize(
+ ("get_devices_fixture_supports_hot_water_plus"),
+ [True],
+)
+async def test_state(
+ hass: HomeAssistant,
+ init_integration: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test the state of the select entity."""
+ await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
+
+
+@pytest.mark.parametrize(
+ ("get_devices_fixture_supports_hot_water_plus"),
+ [True],
+)
+@pytest.mark.parametrize(
+ ("hass_level", "aosmith_level"),
+ [
+ ("off", 0),
+ ("level1", 1),
+ ("level2", 2),
+ ("level3", 3),
+ ],
+)
+async def test_set_hot_water_plus_level(
+ hass: HomeAssistant,
+ mock_client: MagicMock,
+ init_integration: MockConfigEntry,
+ hass_level: str,
+ aosmith_level: int,
+) -> None:
+ """Test setting the Hot Water+ level."""
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: "select.my_water_heater_hot_water_plus_level",
+ ATTR_OPTION: hass_level,
+ },
+ )
+ await hass.async_block_till_done()
+
+ mock_client.update_mode.assert_called_once_with(
+ junction_id="junctionId",
+ mode=OperationMode.HEAT_PUMP,
+ hot_water_plus_level=aosmith_level,
+ )
diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py
index 2a786925e70..27ddd478b9b 100644
--- a/tests/components/apcupsd/__init__.py
+++ b/tests/components/apcupsd/__init__.py
@@ -2,110 +2,69 @@
from __future__ import annotations
-from collections import OrderedDict
from typing import Final
-from unittest.mock import patch
-from homeassistant.components.apcupsd.const import DOMAIN
-from homeassistant.components.apcupsd.coordinator import APCUPSdData
-from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.core import HomeAssistant
-
-from tests.common import MockConfigEntry
CONF_DATA: Final = {CONF_HOST: "test", CONF_PORT: 1234}
-MOCK_STATUS: Final = OrderedDict(
- [
- ("APC", "001,038,0985"),
- ("DATE", "1970-01-01 00:00:00 0000"),
- ("VERSION", "3.14.14 (31 May 2016) unknown"),
- ("CABLE", "USB Cable"),
- ("DRIVER", "USB UPS Driver"),
- ("UPSMODE", "Stand Alone"),
- ("UPSNAME", "MyUPS"),
- ("MODEL", "Back-UPS ES 600"),
- ("STATUS", "ONLINE"),
- ("LINEV", "124.0 Volts"),
- ("LOADPCT", "14.0 Percent"),
- ("BCHARGE", "100.0 Percent"),
- ("TIMELEFT", "51.0 Minutes"),
- ("NOMAPNT", "60.0 VA"),
- ("ITEMP", "34.6 C Internal"),
- ("MBATTCHG", "5 Percent"),
- ("MINTIMEL", "3 Minutes"),
- ("MAXTIME", "0 Seconds"),
- ("SENSE", "Medium"),
- ("LOTRANS", "92.0 Volts"),
- ("HITRANS", "139.0 Volts"),
- ("ALARMDEL", "30 Seconds"),
- ("BATTV", "13.7 Volts"),
- ("OUTCURNT", "0.88 Amps"),
- ("LASTXFER", "Automatic or explicit self test"),
- ("NUMXFERS", "1"),
- ("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"),
- ("SELFTEST", "NO"),
- ("STESTI", "7 days"),
- ("STATFLAG", "0x05000008"),
- ("SERIALNO", "XXXXXXXXXXXX"),
- ("BATTDATE", "1970-01-01"),
- ("NOMINV", "120 Volts"),
- ("NOMBATTV", "12.0 Volts"),
- ("NOMPOWER", "330 Watts"),
- ("FIRMWARE", "928.a8 .D USB FW:a8"),
- ("END APC", "1970-01-01 00:00:00 0000"),
- ]
-)
+MOCK_STATUS: Final = {
+ "APC": "001,038,0985",
+ "DATE": "1970-01-01 00:00:00 0000",
+ "VERSION": "3.14.14 (31 May 2016) unknown",
+ "CABLE": "USB Cable",
+ "DRIVER": "USB UPS Driver",
+ "UPSMODE": "Stand Alone",
+ "UPSNAME": "MyUPS",
+ "APCMODEL": "Back-UPS ES 600",
+ "MODEL": "Back-UPS ES 600",
+ "STATUS": "ONLINE",
+ "LINEV": "124.0 Volts",
+ "LOADPCT": "14.0 Percent",
+ "BCHARGE": "100.0 Percent",
+ "TIMELEFT": "51.0 Minutes",
+ "NOMAPNT": "60.0 VA",
+ "ITEMP": "34.6 C Internal",
+ "MBATTCHG": "5 Percent",
+ "MINTIMEL": "3 Minutes",
+ "MAXTIME": "0 Seconds",
+ "SENSE": "Medium",
+ "LOTRANS": "92.0 Volts",
+ "HITRANS": "139.0 Volts",
+ "ALARMDEL": "30 Seconds",
+ "BATTV": "13.7 Volts",
+ "OUTCURNT": "0.88 Amps",
+ "LASTXFER": "Automatic or explicit self test",
+ "NUMXFERS": "1",
+ "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",
+ "SELFTEST": "NO",
+ "STESTI": "7 days",
+ "STATFLAG": "0x05000008",
+ "SERIALNO": "XXXXXXXXXXXX",
+ "BATTDATE": "1970-01-01",
+ "NOMINV": "120 Volts",
+ "NOMBATTV": "12.0 Volts",
+ "NOMPOWER": "330 Watts",
+ "FIRMWARE": "928.a8 .D USB FW:a8",
+ "END APC": "1970-01-01 00:00:00 0000",
+}
# Minimal status adapted from http://www.apcupsd.org/manual/manual.html#apcaccess-test.
# Most importantly, the "MODEL" and "SERIALNO" fields are removed to test the ability
# of the integration to handle such cases.
-MOCK_MINIMAL_STATUS: Final = OrderedDict(
- [
- ("APC", "001,012,0319"),
- ("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"),
- ("LINEFAIL", "OK"),
- ("BATTSTAT", "OK"),
- ("STATFLAG", "0x008"),
- ("END APC", "1970-01-01 00:00:00 0000"),
- ]
-)
-
-
-async def async_init_integration(
- hass: HomeAssistant,
- *,
- host: str = "test",
- status: dict[str, str] | None = None,
- entry_id: str = "mocked-config-entry-id",
-) -> MockConfigEntry:
- """Set up the APC UPS Daemon integration in HomeAssistant."""
- if status is None:
- status = MOCK_STATUS
-
- entry = MockConfigEntry(
- entry_id=entry_id,
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA | {CONF_HOST: host},
- unique_id=APCUPSdData(status).serial_no,
- source=SOURCE_USER,
- )
-
- entry.add_to_hass(hass)
-
- with patch("aioapcaccess.request_status", return_value=status):
- assert await hass.config_entries.async_setup(entry.entry_id)
- await hass.async_block_till_done()
-
- return entry
+MOCK_MINIMAL_STATUS: Final = {
+ "APC": "001,012,0319",
+ "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",
+ "LINEFAIL": "OK",
+ "BATTSTAT": "OK",
+ "STATFLAG": "0x008",
+ "END APC": "1970-01-01 00:00:00 0000",
+}
diff --git a/tests/components/apcupsd/conftest.py b/tests/components/apcupsd/conftest.py
index 533694fdb1f..300613147cd 100644
--- a/tests/components/apcupsd/conftest.py
+++ b/tests/components/apcupsd/conftest.py
@@ -1,10 +1,21 @@
"""Common fixtures for the APC UPS Daemon (APCUPSD) tests."""
-from collections.abc import Generator
+from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, patch
import pytest
+from homeassistant.components.apcupsd import PLATFORMS
+from homeassistant.components.apcupsd.const import DOMAIN
+from homeassistant.components.apcupsd.coordinator import APCUPSdData
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from . import CONF_DATA, MOCK_STATUS
+
+from tests.common import MockConfigEntry
+
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@@ -13,3 +24,58 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.apcupsd.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
+
+
+@pytest.fixture
+async def mock_request_status(
+ request: pytest.FixtureRequest,
+) -> AsyncGenerator[AsyncMock]:
+ """Return a mocked aioapcaccess.request_status function."""
+ mocked_status = getattr(request, "param", None) or MOCK_STATUS
+
+ with patch("aioapcaccess.request_status") as mock_request_status:
+ mock_request_status.return_value = mocked_status
+ yield mock_request_status
+
+
+@pytest.fixture
+def mock_config_entry(
+ request: pytest.FixtureRequest,
+ mock_request_status: AsyncMock,
+) -> MockConfigEntry:
+ """Mock setting up a config entry."""
+ entry_id = getattr(request, "param", None)
+
+ return MockConfigEntry(
+ entry_id=entry_id,
+ version=1,
+ domain=DOMAIN,
+ title="APC UPS Daemon",
+ data=CONF_DATA,
+ unique_id=APCUPSdData(mock_request_status.return_value).serial_no,
+ source=SOURCE_USER,
+ )
+
+
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Fixture to specify platforms to test."""
+ return PLATFORMS
+
+
+@pytest.fixture
+async def init_integration(
+ request: pytest.FixtureRequest,
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
+ platforms: list[Platform],
+) -> MockConfigEntry:
+ """Set up APC UPS Daemon integration for testing."""
+ mock_config_entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.apcupsd.PLATFORMS", platforms):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ return mock_config_entry
diff --git a/tests/components/apcupsd/snapshots/test_diagnostics.ambr b/tests/components/apcupsd/snapshots/test_diagnostics.ambr
index a3c4d16da2f..669654c75bb 100644
--- a/tests/components/apcupsd/snapshots/test_diagnostics.ambr
+++ b/tests/components/apcupsd/snapshots/test_diagnostics.ambr
@@ -3,6 +3,7 @@
dict({
'ALARMDEL': '30 Seconds',
'APC': '001,038,0985',
+ 'APCMODEL': 'Back-UPS ES 600',
'BATTDATE': '1970-01-01',
'BATTV': '13.7 Volts',
'BCHARGE': '100.0 Percent',
diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr
index 414c3e451fd..3309d384ec7 100644
--- a/tests/components/apcupsd/snapshots/test_init.ambr
+++ b/tests/components/apcupsd/snapshots/test_init.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_async_setup_entry[status0][device_MyUPS_XXXXXXXXXXXX]
+# name: test_async_setup_entry[mock_request_status0-mocked-config-entry-id][device_MyUPS_XXXXXXXXXXXX]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': ,
@@ -25,12 +25,12 @@
'name': 'MyUPS',
'name_by_user': None,
'primary_config_entry': ,
- 'serial_number': None,
+ 'serial_number': 'XXXXXXXXXXXX',
'sw_version': '3.14.14 (31 May 2016) unknown',
'via_device_id': None,
})
# ---
-# name: test_async_setup_entry[status1][device_APC UPS_XXXX]
+# name: test_async_setup_entry[mock_request_status1-mocked-config-entry-id][device_APC UPS_XXXX]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': ,
@@ -56,12 +56,12 @@
'name': 'APC UPS',
'name_by_user': None,
'primary_config_entry': ,
- 'serial_number': None,
+ 'serial_number': 'XXXX',
'sw_version': None,
'via_device_id': None,
})
# ---
-# name: test_async_setup_entry[status2][device_APC UPS_]
+# name: test_async_setup_entry[mock_request_status2-mocked-config-entry-id][device_APC UPS_]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': ,
@@ -92,7 +92,7 @@
'via_device_id': None,
})
# ---
-# name: test_async_setup_entry[status3][device_APC UPS_Blank]
+# name: test_async_setup_entry[mock_request_status3-mocked-config-entry-id][device_APC UPS_Blank]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': ,
diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr
index 2e991d7cfa6..4e9626bec6b 100644
--- a/tests/components/apcupsd/snapshots/test_sensor.ambr
+++ b/tests/components/apcupsd/snapshots/test_sensor.ambr
@@ -868,7 +868,7 @@
'device_id': ,
'disabled_by': None,
'domain': 'sensor',
- 'entity_category': None,
+ 'entity_category': ,
'entity_id': 'sensor.myups_mode',
'has_entity_name': True,
'hidden_by': None,
@@ -934,8 +934,8 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
- 'translation_key': 'model',
- 'unique_id': 'XXXXXXXXXXXX_model',
+ 'translation_key': 'apc_model',
+ 'unique_id': 'XXXXXXXXXXXX_apcmodel',
'unit_of_measurement': None,
})
# ---
@@ -952,6 +952,54 @@
'state': 'Back-UPS ES 600',
})
# ---
+# name: test_sensor[sensor.myups_model_2-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_model_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Model',
+ 'platform': 'apcupsd',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'model',
+ 'unique_id': 'XXXXXXXXXXXX_model',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensor[sensor.myups_model_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'MyUPS Model',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.myups_model_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'Back-UPS ES 600',
+ })
+# ---
# name: test_sensor[sensor.myups_name-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py
index 0bf1c00d2f3..5548c1712f1 100644
--- a/tests/components/apcupsd/test_binary_sensor.py
+++ b/tests/components/apcupsd/test_binary_sensor.py
@@ -1,56 +1,83 @@
"""Test binary sensors of APCUPSd integration."""
-from unittest.mock import patch
+from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.apcupsd.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.util import slugify
-from . import MOCK_STATUS, async_init_integration
+from . import MOCK_STATUS
-from tests.common import snapshot_platform
+from tests.common import MockConfigEntry, snapshot_platform
+
+pytestmark = pytest.mark.usefixtures(
+ "entity_registry_enabled_by_default", "init_integration"
+)
+
+
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Overridden fixture to specify platforms to test."""
+ return [Platform.BINARY_SENSOR]
async def test_binary_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
- """Test states of binary sensors."""
- with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.BINARY_SENSOR]):
- config_entry = await async_init_integration(hass, status=MOCK_STATUS)
- await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
+ """Test states of binary sensor entities."""
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+ # Ensure entities are correctly assigned to device
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, mock_request_status.return_value["SERIALNO"])}
+ )
+ assert device_entry
+ entity_entries = er.async_entries_for_config_entry(
+ entity_registry, mock_config_entry.entry_id
+ )
+ for entity_entry in entity_entries:
+ assert entity_entry.device_id == device_entry.id
-async def test_no_binary_sensor(hass: HomeAssistant) -> None:
+@pytest.mark.parametrize(
+ "mock_request_status",
+ [{k: v for k, v in MOCK_STATUS.items() if k != "STATFLAG"}],
+ indirect=True,
+)
+async def test_no_binary_sensor(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+) -> None:
"""Test binary sensor when STATFLAG is not available."""
- status = MOCK_STATUS.copy()
- status.pop("STATFLAG")
- await async_init_integration(hass, status=status)
-
- device_slug = slugify(MOCK_STATUS["UPSNAME"])
+ device_slug = slugify(mock_request_status.return_value["UPSNAME"])
state = hass.states.get(f"binary_sensor.{device_slug}_online_status")
assert state is None
@pytest.mark.parametrize(
- ("override", "expected"),
+ ("mock_request_status", "expected"),
[
- ("0x008", "on"),
- ("0x02040010 Status Flag", "off"),
+ (MOCK_STATUS | {"STATFLAG": "0x008"}, "on"),
+ (MOCK_STATUS | {"STATFLAG": "0x02040010 Status Flag"}, "off"),
],
+ indirect=["mock_request_status"],
)
-async def test_statflag(hass: HomeAssistant, override: str, expected: str) -> None:
+async def test_statflag(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+ expected: str,
+) -> None:
"""Test binary sensor for different STATFLAG values."""
- status = MOCK_STATUS.copy()
- status["STATFLAG"] = override
- await async_init_integration(hass, status=status)
-
- device_slug = slugify(MOCK_STATUS["UPSNAME"])
- assert (
- hass.states.get(f"binary_sensor.{device_slug}_online_status").state == expected
- )
+ device_slug = slugify(mock_request_status.return_value["UPSNAME"])
+ state = hass.states.get(f"binary_sensor.{device_slug}_online_status")
+ assert state.state == expected
diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py
index 0a61d8c0ddb..f33b1472c92 100644
--- a/tests/components/apcupsd/test_config_flow.py
+++ b/tests/components/apcupsd/test_config_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock
import pytest
@@ -23,248 +23,202 @@ from tests.common import MockConfigEntry
[OSError(), asyncio.IncompleteReadError(partial=b"", expected=100), TimeoutError()],
)
async def test_config_flow_cannot_connect(
- hass: HomeAssistant, exception: Exception
+ hass: HomeAssistant,
+ exception: Exception,
+ mock_request_status: AsyncMock,
) -> None:
"""Test config flow setup with a connection error."""
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status"
- ) as mock_request_status:
- mock_request_status.side_effect = exception
+ mock_request_status.side_effect = exception
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_USER},
- data=CONF_DATA,
- )
- assert result["type"] is FlowResultType.FORM
- assert result["errors"]["base"] == "cannot_connect"
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"]["base"] == "cannot_connect"
async def test_config_flow_duplicate_host_port(
- hass: HomeAssistant, mock_setup_entry: AsyncMock
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test duplicate config flow setup with the same host / port."""
- # First add an existing config entry to hass.
- mock_entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- unique_id=MOCK_STATUS["SERIALNO"],
- source=SOURCE_USER,
+ mock_config_entry.add_to_hass(hass)
+
+ # Assign the same host and port, which we should reject since the entry already exists.
+ mock_request_status.return_value = MOCK_STATUS
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA
)
- mock_entry.add_to_hass(hass)
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status"
- ) as mock_request_status:
- # Assign the same host and port, which we should reject since the entry already exists.
- mock_request_status.return_value = MOCK_STATUS
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA
- )
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "already_configured"
-
- # Now we change the host with a different serial number and add it again. This should be successful.
- another_host = CONF_DATA | {CONF_HOST: "another_host"}
- mock_request_status.return_value = MOCK_STATUS | {
- "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ"
- }
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_USER},
- data=another_host,
- )
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["data"] == another_host
+ # Now we change the host with a different serial number and add it again. This should be successful.
+ another_host = CONF_DATA | {CONF_HOST: "another_host"}
+ mock_request_status.return_value = MOCK_STATUS | {
+ "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ"
+ }
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=another_host,
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == another_host
async def test_config_flow_duplicate_serial_number(
- hass: HomeAssistant, mock_setup_entry: AsyncMock
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test duplicate config flow setup with different host but the same serial number."""
- # First add an existing config entry to hass.
- mock_entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- unique_id=MOCK_STATUS["SERIALNO"],
- source=SOURCE_USER,
+ mock_config_entry.add_to_hass(hass)
+
+ # Assign the different host and port, but we should still reject the creation since the
+ # serial number is the same as the existing entry.
+ mock_request_status.return_value = MOCK_STATUS
+ another_host = CONF_DATA | {CONF_HOST: "another_host"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=another_host,
)
- mock_entry.add_to_hass(hass)
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status"
- ) as mock_request_status:
- # Assign the different host and port, but we should still reject the creation since the
- # serial number is the same as the existing entry.
- mock_request_status.return_value = MOCK_STATUS
- another_host = CONF_DATA | {CONF_HOST: "another_host"}
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_USER},
- data=another_host,
- )
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "already_configured"
-
- # Now we change the serial number and add it again. This should be successful.
- mock_request_status.return_value = MOCK_STATUS | {
- "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ"
- }
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=another_host
- )
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["data"] == another_host
+ # Now we change the serial number and add it again. This should be successful.
+ mock_request_status.return_value = MOCK_STATUS | {
+ "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ"
+ }
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=another_host
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == another_host
-async def test_flow_works(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
+async def test_flow_works(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_request_status: AsyncMock,
+) -> None:
"""Test successful creation of config entries via user configuration."""
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status",
- return_value=MOCK_STATUS,
- ):
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={CONF_SOURCE: SOURCE_USER},
- )
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=CONF_DATA
- )
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["title"] == MOCK_STATUS["UPSNAME"]
- assert result["data"] == CONF_DATA
- assert result["result"].unique_id == MOCK_STATUS["SERIALNO"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=CONF_DATA
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == MOCK_STATUS["UPSNAME"]
+ assert result["data"] == CONF_DATA
+ assert result["result"].unique_id == MOCK_STATUS["SERIALNO"]
- mock_setup_entry.assert_called_once()
+ mock_setup_entry.assert_called_once()
@pytest.mark.parametrize(
- ("extra_status", "expected_title"),
+ ("mock_request_status", "expected_title"),
[
- ({"UPSNAME": "Friendly Name"}, "Friendly Name"),
- ({"MODEL": "MODEL X"}, "MODEL X"),
- ({"SERIALNO": "ZZZZ"}, "ZZZZ"),
- # Some models report "Blank" as serial number, which we should treat it as not reported.
- ({"SERIALNO": "Blank"}, "APC UPS"),
- ({}, "APC UPS"),
+ (MOCK_MINIMAL_STATUS | {"UPSNAME": "Friendly Name"}, "Friendly Name"),
+ (MOCK_MINIMAL_STATUS | {"MODEL": "MODEL X"}, "MODEL X"),
+ (MOCK_MINIMAL_STATUS | {"SERIALNO": "ZZZZ"}, "ZZZZ"),
+ # Some models report "Blank" as the serial number, which we should treat it as not reported.
+ (MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, "APC UPS"),
+ (MOCK_MINIMAL_STATUS | {}, "APC UPS"),
],
+ indirect=["mock_request_status"],
)
async def test_flow_minimal_status(
hass: HomeAssistant,
- extra_status: dict[str, str],
expected_title: str,
mock_setup_entry: AsyncMock,
+ mock_request_status: AsyncMock,
) -> None:
"""Test successful creation of config entries via user configuration when minimal status is reported.
We test different combinations of minimal statuses, where the title of the
integration will vary.
"""
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status"
- ) as mock_request_status:
- status = MOCK_MINIMAL_STATUS | extra_status
- mock_request_status.return_value = status
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
- )
- await hass.async_block_till_done()
- assert result["type"] is FlowResultType.CREATE_ENTRY
- assert result["data"] == CONF_DATA
- assert result["title"] == expected_title
- mock_setup_entry.assert_called_once()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
+ )
+ await hass.async_block_till_done()
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == CONF_DATA
+ assert result["title"] == expected_title
+ mock_setup_entry.assert_called_once()
async def test_reconfigure_flow_works(
- hass: HomeAssistant, mock_setup_entry: AsyncMock
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test successful reconfiguration of an existing entry."""
- mock_entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- unique_id=MOCK_STATUS["SERIALNO"],
- source=SOURCE_USER,
- )
- mock_entry.add_to_hass(hass)
+ mock_config_entry.add_to_hass(hass)
- result = await mock_entry.start_reconfigure_flow(hass)
+ result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# New configuration data with different host/port.
new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321}
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status",
- return_value=MOCK_STATUS,
- ):
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=new_conf_data
- )
- await hass.async_block_till_done()
- mock_setup_entry.assert_called_once()
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=new_conf_data
+ )
+ await hass.async_block_till_done()
+ mock_setup_entry.assert_called_once()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Check that the entry was updated with the new configuration.
- assert mock_entry.data[CONF_HOST] == new_conf_data[CONF_HOST]
- assert mock_entry.data[CONF_PORT] == new_conf_data[CONF_PORT]
+ assert mock_config_entry.data[CONF_HOST] == new_conf_data[CONF_HOST]
+ assert mock_config_entry.data[CONF_PORT] == new_conf_data[CONF_PORT]
async def test_reconfigure_flow_cannot_connect(
- hass: HomeAssistant, mock_setup_entry: AsyncMock
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test reconfiguration with connection error and recovery."""
- mock_entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- unique_id=MOCK_STATUS["SERIALNO"],
- source=SOURCE_USER,
- )
- mock_entry.add_to_hass(hass)
+ mock_config_entry.add_to_hass(hass)
- result = await mock_entry.start_reconfigure_flow(hass)
+ result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# New configuration data with different host/port.
new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321}
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status",
- side_effect=OSError(),
- ):
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=new_conf_data
- )
+ mock_request_status.side_effect = OSError()
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=new_conf_data
+ )
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
# Test recovery by fixing the connection issue.
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status",
- return_value=MOCK_STATUS,
- ):
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=new_conf_data
- )
+ mock_request_status.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=new_conf_data
+ )
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
- assert mock_entry.data == new_conf_data
+ assert mock_config_entry.data == new_conf_data
@pytest.mark.parametrize(
@@ -276,35 +230,27 @@ async def test_reconfigure_flow_cannot_connect(
],
)
async def test_reconfigure_flow_wrong_device(
- hass: HomeAssistant, unique_id_before: str | None, unique_id_after: str | None
+ hass: HomeAssistant,
+ unique_id_before: str | None,
+ unique_id_after: str,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test reconfiguration with a different device (wrong serial number)."""
- mock_entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- unique_id=unique_id_before,
- source=SOURCE_USER,
+ mock_config_entry.add_to_hass(hass)
+ hass.config_entries.async_update_entry(
+ mock_config_entry, unique_id=unique_id_before
)
- mock_entry.add_to_hass(hass)
- result = await mock_entry.start_reconfigure_flow(hass)
+ result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# New configuration data with different host/port.
- new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321}
- # Make a copy of the status and modify the serial number if needed.
- mock_status = {k: v for k, v in MOCK_STATUS.items() if k != "SERIALNO"}
- mock_status["SERIALNO"] = unique_id_after
- with patch(
- "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status",
- return_value=mock_status,
- ):
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=new_conf_data
- )
+ mock_request_status.return_value = MOCK_STATUS | {"SERIALNO": unique_id_after}
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: "new_host", CONF_PORT: 4321}
+ )
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_apcupsd_daemon"
diff --git a/tests/components/apcupsd/test_diagnostics.py b/tests/components/apcupsd/test_diagnostics.py
index 67946a928f8..58612f05fa9 100644
--- a/tests/components/apcupsd/test_diagnostics.py
+++ b/tests/components/apcupsd/test_diagnostics.py
@@ -4,8 +4,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
-from . import async_init_integration
-
+from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@@ -14,7 +13,8 @@ async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
+ init_integration: MockConfigEntry,
) -> None:
"""Test diagnostics."""
- entry = await async_init_integration(hass)
+ entry = init_integration
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py
index 4f6b55fe317..13abb00141d 100644
--- a/tests/components/apcupsd/test_init.py
+++ b/tests/components/apcupsd/test_init.py
@@ -1,28 +1,28 @@
"""Test init of APCUPSd integration."""
import asyncio
-from collections import OrderedDict
-from unittest.mock import patch
+from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.apcupsd.const import DOMAIN
from homeassistant.components.apcupsd.coordinator import UPDATE_INTERVAL
-from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.util import slugify, utcnow
-from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration
+from . import MOCK_MINIMAL_STATUS, MOCK_STATUS
from tests.common import MockConfigEntry, async_fire_time_changed
+@pytest.mark.parametrize("mock_config_entry", ["mocked-config-entry-id"], indirect=True)
@pytest.mark.parametrize(
- "status",
+ "mock_request_status",
[
# Contains "SERIALNO" and "UPSNAME" fields.
# We should create devices for the entities and prefix their IDs with "MyUPS".
@@ -32,23 +32,26 @@ from tests.common import MockConfigEntry, async_fire_time_changed
MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"},
# Does not contain either "SERIALNO" field or "UPSNAME" field.
# Our integration should work fine without it by falling back to config entry ID as unique
- # ID and "APC UPS" as default name.
+ # ID and "APC UPS" as the default name.
MOCK_MINIMAL_STATUS,
# Some models report "Blank" as SERIALNO, but we should treat it as not reported.
MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"},
],
+ indirect=True,
)
async def test_async_setup_entry(
hass: HomeAssistant,
- status: OrderedDict,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
+ init_integration: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
"""Test a successful setup entry."""
- config_entry = await async_init_integration(hass, status=status)
- device_entry = device_registry.async_get_device(
- identifiers={(DOMAIN, config_entry.unique_id or config_entry.entry_id)}
- )
+ status = mock_request_status.return_value
+ entry = init_integration
+
+ identifiers = {(DOMAIN, entry.unique_id or entry.entry_id)}
+ device_entry = device_registry.async_get_device(identifiers=identifiers)
name = f"device_{device_entry.name}_{status.get('SERIALNO', '')}"
assert device_entry == snapshot(name=name)
@@ -61,28 +64,26 @@ async def test_async_setup_entry(
"error",
[OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)],
)
-async def test_connection_error(hass: HomeAssistant, error: Exception) -> None:
+async def test_connection_error(
+ hass: HomeAssistant,
+ error: Exception,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
+) -> None:
"""Test connection error during integration setup."""
- entry = MockConfigEntry(
- version=1,
- domain=DOMAIN,
- title="APCUPSd",
- data=CONF_DATA,
- source=SOURCE_USER,
- )
+ mock_config_entry.add_to_hass(hass)
+ mock_request_status.side_effect = error
- entry.add_to_hass(hass)
-
- with patch("aioapcaccess.request_status", side_effect=error):
- await hass.config_entries.async_setup(entry.entry_id)
- assert entry.state is ConfigEntryState.SETUP_RETRY
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
-async def test_unload_remove_entry(hass: HomeAssistant) -> None:
+async def test_unload_remove_entry(
+ hass: HomeAssistant,
+ init_integration: MockConfigEntry,
+) -> None:
"""Test successful unload and removal of an entry."""
- entry = await async_init_integration(
- hass, host="test1", status=MOCK_STATUS, entry_id="entry-id-1"
- )
+ entry = init_integration
assert entry.state is ConfigEntryState.LOADED
# Unload the entry.
@@ -96,37 +97,38 @@ async def test_unload_remove_entry(hass: HomeAssistant) -> None:
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
-async def test_availability(hass: HomeAssistant) -> None:
+async def test_availability(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+ init_integration: MockConfigEntry,
+) -> None:
"""Ensure that we mark the entity's availability properly when network is down / back up."""
- await async_init_integration(hass)
-
- device_slug = slugify(MOCK_STATUS["UPSNAME"])
+ device_slug = slugify(mock_request_status.return_value["UPSNAME"])
state = hass.states.get(f"sensor.{device_slug}_load")
assert state
assert state.state != STATE_UNAVAILABLE
assert pytest.approx(float(state.state)) == 14.0
- with patch("aioapcaccess.request_status") as mock_request_status:
- # Mock a network error and then trigger an auto-polling event.
- mock_request_status.side_effect = OSError()
- future = utcnow() + UPDATE_INTERVAL
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ # Mock a network error and then trigger an auto-polling event.
+ mock_request_status.side_effect = OSError()
+ future = utcnow() + UPDATE_INTERVAL
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- # Sensors should be marked as unavailable.
- state = hass.states.get(f"sensor.{device_slug}_load")
- assert state
- assert state.state == STATE_UNAVAILABLE
+ # Sensors should be marked as unavailable.
+ state = hass.states.get(f"sensor.{device_slug}_load")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
- # Reset the API to return a new status and update.
- mock_request_status.side_effect = None
- mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"}
- future = future + UPDATE_INTERVAL
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ # Reset the API to return a new status and update.
+ mock_request_status.side_effect = None
+ mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"}
+ future = future + UPDATE_INTERVAL
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- # Sensors should be online now with the new value.
- state = hass.states.get(f"sensor.{device_slug}_load")
- assert state
- assert state.state != STATE_UNAVAILABLE
- assert pytest.approx(float(state.state)) == 15.0
+ # Sensors should be online now with the new value.
+ state = hass.states.get(f"sensor.{device_slug}_load")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert pytest.approx(float(state.state)) == 15.0
diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py
index af163d3cbc1..9dadffe6fb3 100644
--- a/tests/components/apcupsd/test_sensor.py
+++ b/tests/components/apcupsd/test_sensor.py
@@ -1,11 +1,12 @@
"""Test sensors of APCUPSd integration."""
from datetime import timedelta
-from unittest.mock import patch
+from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.apcupsd.const import DOMAIN
from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -14,58 +15,80 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
-from . import MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration
+from . import MOCK_MINIMAL_STATUS, MOCK_STATUS
-from tests.common import async_fire_time_changed, snapshot_platform
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+pytestmark = pytest.mark.usefixtures(
+ "entity_registry_enabled_by_default", "init_integration"
+)
+
+
+@pytest.fixture
+def platforms() -> list[Platform]:
+ """Overridden fixture to specify platforms to test."""
+ return [Platform.SENSOR]
-@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
+ device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
+ mock_config_entry: MockConfigEntry,
+ mock_request_status: AsyncMock,
) -> None:
- """Test states of sensor."""
- with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.SENSOR]):
- config_entry = await async_init_integration(hass, status=MOCK_STATUS)
- await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
+ """Test states of sensor entities."""
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+ # Ensure entities are correctly assigned to device
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, mock_request_status.return_value["SERIALNO"])}
+ )
+ assert device_entry
+ entity_entries = er.async_entries_for_config_entry(
+ entity_registry, mock_config_entry.entry_id
+ )
+ for entity_entry in entity_entries:
+ assert entity_entry.device_id == device_entry.id
-async def test_state_update(hass: HomeAssistant) -> None:
+async def test_state_update(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+) -> None:
"""Ensure the sensor state changes after updating the data."""
- await async_init_integration(hass)
-
- device_slug = slugify(MOCK_STATUS["UPSNAME"])
+ device_slug = slugify(mock_request_status.return_value["UPSNAME"])
state = hass.states.get(f"sensor.{device_slug}_load")
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "14.0"
- new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"}
- with patch("aioapcaccess.request_status", return_value=new_status):
- future = utcnow() + timedelta(minutes=2)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"}
+ future = utcnow() + timedelta(minutes=2)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- state = hass.states.get(f"sensor.{device_slug}_load")
- assert state
- assert state.state != STATE_UNAVAILABLE
- assert state.state == "15.0"
+ state = hass.states.get(f"sensor.{device_slug}_load")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "15.0"
-async def test_manual_update_entity(hass: HomeAssistant) -> None:
+async def test_manual_update_entity(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+) -> None:
"""Test multiple simultaneous manual update entity via service homeassistant/update_entity.
We should only do network call once for the multiple simultaneous update entity services.
"""
- await async_init_integration(hass)
-
- device_slug = slugify(MOCK_STATUS["UPSNAME"])
+ device_slug = slugify(mock_request_status.return_value["UPSNAME"])
# Assert the initial state of sensor.ups_load.
state = hass.states.get(f"sensor.{device_slug}_load")
assert state
@@ -75,41 +98,43 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None:
# Setup HASS for calling the update_entity service.
await async_setup_component(hass, "homeassistant", {})
- with patch("aioapcaccess.request_status") as mock_request_status:
- mock_request_status.return_value = MOCK_STATUS | {
- "LOADPCT": "15.0 Percent",
- "BCHARGE": "99.0 Percent",
- }
- # Now, we fast-forward the time to pass the debouncer cooldown, but put it
- # before the normal update interval to see if the manual update works.
- future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN)
- async_fire_time_changed(hass, future)
- await hass.services.async_call(
- "homeassistant",
- "update_entity",
- {
- ATTR_ENTITY_ID: [
- f"sensor.{device_slug}_load",
- f"sensor.{device_slug}_battery",
- ]
- },
- blocking=True,
- )
- # Even if we requested updates for two entities, our integration should smartly
- # group the API calls to just one.
- assert mock_request_status.call_count == 1
+ mock_request_status.return_value = MOCK_STATUS | {
+ "LOADPCT": "15.0 Percent",
+ "BCHARGE": "99.0 Percent",
+ }
+ # Now, we fast-forward the time to pass the debouncer cooldown, but put it
+ # before the normal update interval to see if the manual update works.
+ request_call_count_before = mock_request_status.call_count
+ future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN)
+ async_fire_time_changed(hass, future)
+ await hass.services.async_call(
+ "homeassistant",
+ "update_entity",
+ {
+ ATTR_ENTITY_ID: [
+ f"sensor.{device_slug}_load",
+ f"sensor.{device_slug}_battery",
+ ]
+ },
+ blocking=True,
+ )
+ # Even if we requested updates for two entities, our integration should smartly
+ # group the API calls to just one.
+ assert mock_request_status.call_count == request_call_count_before + 1
- # The new state should be effective.
- state = hass.states.get(f"sensor.{device_slug}_load")
- assert state
- assert state.state != STATE_UNAVAILABLE
- assert state.state == "15.0"
+ # The new state should be effective.
+ state = hass.states.get(f"sensor.{device_slug}_load")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "15.0"
-async def test_sensor_unknown(hass: HomeAssistant) -> None:
+@pytest.mark.parametrize("mock_request_status", [MOCK_MINIMAL_STATUS], indirect=True)
+async def test_sensor_unknown(
+ hass: HomeAssistant,
+ mock_request_status: AsyncMock,
+) -> None:
"""Test if our integration can properly mark certain sensors as unknown when it becomes so."""
- await async_init_integration(hass, status=MOCK_MINIMAL_STATUS)
-
ups_mode_id = "sensor.apc_ups_mode"
last_self_test_id = "sensor.apc_ups_last_self_test"
@@ -121,20 +146,18 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None:
# 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.
- with patch("aioapcaccess.request_status") as mock_request_status:
- mock_request_status.return_value = MOCK_MINIMAL_STATUS | {
- "LASTSTEST": "1970-01-01 00:00:00 0000"
- }
- future = utcnow() + timedelta(minutes=2)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ mock_request_status.return_value = MOCK_MINIMAL_STATUS | {
+ "LASTSTEST": "1970-01-01 00:00:00 0000"
+ }
+ 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"
# Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported.
- with patch("aioapcaccess.request_status") as mock_request_status:
- mock_request_status.return_value = MOCK_MINIMAL_STATUS
- future = utcnow() + timedelta(minutes=2)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ mock_request_status.return_value = MOCK_MINIMAL_STATUS
+ future = utcnow() + timedelta(minutes=2)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
# The state should become unknown again.
assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN
diff --git a/tests/components/api/snapshots/test_init.ambr b/tests/components/api/snapshots/test_init.ambr
index 05b6bf31638..d6277e7fc97 100644
--- a/tests/components/api/snapshots/test_init.ambr
+++ b/tests/components/api/snapshots/test_init.ambr
@@ -20,6 +20,7 @@
'required': True,
'selector': dict({
'object': dict({
+ 'multiple': False,
}),
}),
}),
@@ -74,6 +75,8 @@
'name': 'Name',
'selector': dict({
'text': dict({
+ 'multiline': False,
+ 'multiple': False,
}),
}),
}),
@@ -84,6 +87,8 @@
'required': True,
'selector': dict({
'text': dict({
+ 'multiline': False,
+ 'multiple': False,
}),
}),
}),
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index 382b88b89ea..c000c1c3181 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -338,7 +338,7 @@ async def test_api_get_services(
assert data == snapshot
# Set up an integration with legacy translations in services.yaml
- def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
+ def _load_services_file(integration: Integration) -> JSON_TYPE:
return {
"set_default_level": {
"description": "Translated description",
diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py
index 681f6e7759d..19be971f36c 100644
--- a/tests/components/assist_pipeline/conftest.py
+++ b/tests/components/assist_pipeline/conftest.py
@@ -298,7 +298,7 @@ async def init_supporting_components(
assert await async_setup_component(hass, "conversation", {"conversation": {}})
# Disable fuzzy matching by default for tests
- agent = hass.data[conversation.DATA_DEFAULT_ENTITY]
+ agent = conversation.async_get_agent(hass)
agent.fuzzy_matching = False
config_entry = MockConfigEntry(domain="test")
diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr
index 4ae4b5dce4c..5e77b7e9291 100644
--- a/tests/components/assist_pipeline/snapshots/test_init.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_init.ambr
@@ -45,6 +45,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -75,6 +76,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
@@ -145,6 +147,7 @@
'intent_input': 'test transcript',
'language': 'en-US',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -175,6 +178,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'test',
'language': 'en-US',
'tts_input': "Sorry, I couldn't understand that",
@@ -245,6 +249,7 @@
'intent_input': 'test transcript',
'language': 'en-US',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -275,6 +280,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'test',
'language': 'en-US',
'tts_input': "Sorry, I couldn't understand that",
@@ -369,6 +375,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -399,6 +406,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr
index b6354b2342b..e92f3aec3fb 100644
--- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr
@@ -23,6 +23,7 @@
'intent_input': 'Set a timer',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -130,6 +131,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': 'hello, how are you?',
@@ -178,6 +180,7 @@
'intent_input': 'Set a timer',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -363,6 +366,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "hello, how are you? I'm doing well, thank you. What about you?!",
@@ -411,6 +415,7 @@
'intent_input': 'Set a timer',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -592,6 +597,7 @@
}),
dict({
'data': dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "I'm doing well, thank you.",
@@ -634,6 +640,7 @@
'intent_input': 'test input',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -687,6 +694,7 @@
'intent_input': 'test input',
'language': 'en-US',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
@@ -740,6 +748,7 @@
'intent_input': 'test input',
'language': 'en-us',
'prefer_local_intents': False,
+ 'satellite_id': None,
}),
'type': ,
}),
diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
index 4f29fd79568..5b5ed44e24d 100644
--- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
@@ -44,6 +44,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_audio_pipeline.4
@@ -72,6 +73,7 @@
# ---
# name: test_audio_pipeline.5
dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
@@ -136,6 +138,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_audio_pipeline_debug.4
@@ -164,6 +167,7 @@
# ---
# name: test_audio_pipeline_debug.5
dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
@@ -240,6 +244,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_audio_pipeline_with_enhancements.4
@@ -268,6 +273,7 @@
# ---
# name: test_audio_pipeline_with_enhancements.5
dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
@@ -354,6 +360,7 @@
'intent_input': 'test transcript',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_audio_pipeline_with_wake_word_no_timeout.6
@@ -382,6 +389,7 @@
# ---
# name: test_audio_pipeline_with_wake_word_no_timeout.7
dict({
+ 'acknowledge_override': False,
'engine': 'tts.test',
'language': 'en_US',
'tts_input': "Sorry, I couldn't understand that",
@@ -575,6 +583,7 @@
'intent_input': 'Are the lights on?',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_intent_failed.2
@@ -599,6 +608,7 @@
'intent_input': 'Are the lights on?',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_intent_timeout.2
@@ -635,6 +645,7 @@
'intent_input': 'never mind',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_pipeline_empty_tts_output.2
@@ -785,6 +796,7 @@
'intent_input': 'Are the lights on?',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_text_only_pipeline[extra_msg0].2
@@ -833,6 +845,7 @@
'intent_input': 'Are the lights on?',
'language': 'en',
'prefer_local_intents': False,
+ 'satellite_id': None,
})
# ---
# name: test_text_only_pipeline[extra_msg1].2
diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py
index 0cb67302700..fc2d6d18a6a 100644
--- a/tests/components/assist_pipeline/test_pipeline.py
+++ b/tests/components/assist_pipeline/test_pipeline.py
@@ -16,13 +16,14 @@ from homeassistant.components import (
stt,
tts,
)
-from homeassistant.components.assist_pipeline.const import DOMAIN
+from homeassistant.components.assist_pipeline.const import ACKNOWLEDGE_PATH, DOMAIN
from homeassistant.components.assist_pipeline.pipeline import (
STORAGE_KEY,
STORAGE_VERSION,
STORAGE_VERSION_MINOR,
Pipeline,
PipelineData,
+ PipelineEventType,
PipelineStorageCollection,
PipelineStore,
_async_local_fallback_intent_filter,
@@ -31,9 +32,16 @@ from homeassistant.components.assist_pipeline.pipeline import (
async_get_pipelines,
async_update_pipeline,
)
-from homeassistant.const import MATCH_ALL
+from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL
from homeassistant.core import Context, HomeAssistant
-from homeassistant.helpers import chat_session, intent, llm
+from homeassistant.helpers import (
+ area_registry as ar,
+ chat_session,
+ device_registry as dr,
+ entity_registry as er,
+ intent,
+ llm,
+)
from homeassistant.setup import async_setup_component
from . import MANY_LANGUAGES, process_events
@@ -46,7 +54,7 @@ from .conftest import (
make_10ms_chunk,
)
-from tests.common import flush_store
+from tests.common import MockConfigEntry, async_mock_service, flush_store
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -1707,6 +1715,7 @@ async def test_chat_log_tts_streaming(
language: str | None = None,
agent_id: str | None = None,
device_id: str | None = None,
+ satellite_id: str | None = None,
extra_system_prompt: str | None = None,
):
"""Mock converse."""
@@ -1715,6 +1724,7 @@ async def test_chat_log_tts_streaming(
context=context,
conversation_id=conversation_id,
device_id=device_id,
+ satellite_id=satellite_id,
language=language,
agent_id=agent_id,
extra_system_prompt=extra_system_prompt,
@@ -1785,3 +1795,305 @@ async def test_chat_log_tts_streaming(
assert "".join(received_tts) == chunk_text
assert process_events(events) == snapshot
+
+
+@pytest.mark.parametrize(("use_satellite_entity"), [True, False])
+async def test_acknowledge(
+ hass: HomeAssistant,
+ init_components,
+ pipeline_data: assist_pipeline.pipeline.PipelineData,
+ mock_chat_session: chat_session.ChatSession,
+ entity_registry: er.EntityRegistry,
+ area_registry: ar.AreaRegistry,
+ device_registry: dr.DeviceRegistry,
+ use_satellite_entity: bool,
+) -> None:
+ """Test that acknowledge sound is played when targets are in the same area."""
+ area_1 = area_registry.async_get_or_create("area_1")
+
+ light_1 = entity_registry.async_get_or_create("light", "demo", "1234")
+ hass.states.async_set(light_1.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 1"})
+ light_1 = entity_registry.async_update_entity(light_1.entity_id, area_id=area_1.id)
+
+ light_2 = entity_registry.async_get_or_create("light", "demo", "5678")
+ hass.states.async_set(light_2.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 2"})
+ light_2 = entity_registry.async_update_entity(light_2.entity_id, area_id=area_1.id)
+
+ entry = MockConfigEntry()
+ entry.add_to_hass(hass)
+
+ satellite = entity_registry.async_get_or_create("assist_satellite", "test", "1234")
+ entity_registry.async_update_entity(satellite.entity_id, area_id=area_1.id)
+
+ satellite_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections=set(),
+ identifiers={("demo", "id-1234")},
+ )
+ device_registry.async_update_device(satellite_device.id, area_id=area_1.id)
+
+ events: list[assist_pipeline.PipelineEvent] = []
+ turn_on = async_mock_service(hass, "light", "turn_on")
+
+ pipeline_store = pipeline_data.pipeline_store
+ pipeline_id = pipeline_store.async_get_preferred_item()
+ pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id)
+
+ async def _run(text: str) -> None:
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input=text,
+ session=mock_chat_session,
+ satellite_id=satellite.entity_id if use_satellite_entity else None,
+ device_id=satellite_device.id if not use_satellite_entity else None,
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.TTS,
+ event_callback=events.append,
+ ),
+ )
+ await pipeline_input.validate()
+ await pipeline_input.execute()
+
+ with patch(
+ "homeassistant.components.assist_pipeline.PipelineRun.text_to_speech"
+ ) as text_to_speech:
+
+ def _reset() -> None:
+ events.clear()
+ text_to_speech.reset_mock()
+ turn_on.clear()
+
+ # 1. All targets in same area
+ await _run("turn on the lights")
+
+ # Acknowledgment sound should be played (same area)
+ text_to_speech.assert_called_once()
+ assert (
+ text_to_speech.call_args.kwargs["override_media_path"] == ACKNOWLEDGE_PATH
+ )
+ assert len(turn_on) == 2
+
+ # 2. One light in a different area
+ area_2 = area_registry.async_get_or_create("area_2")
+ light_2 = entity_registry.async_update_entity(
+ light_2.entity_id, area_id=area_2.id
+ )
+
+ _reset()
+ await _run("turn on light 2")
+
+ # Acknowledgment sound should be not played (different area)
+ text_to_speech.assert_called_once()
+ assert text_to_speech.call_args.kwargs.get("override_media_path") is None
+ assert len(turn_on) == 1
+
+ # Restore
+ light_2 = entity_registry.async_update_entity(
+ light_2.entity_id, area_id=area_1.id
+ )
+
+ # 3. Remove satellite device area
+ entity_registry.async_update_entity(satellite.entity_id, area_id=None)
+ device_registry.async_update_device(satellite_device.id, area_id=None)
+
+ _reset()
+ await _run("turn on light 1")
+
+ # Acknowledgment sound should be not played (no satellite area)
+ text_to_speech.assert_called_once()
+ assert text_to_speech.call_args.kwargs.get("override_media_path") is None
+ assert len(turn_on) == 1
+
+ # Restore
+ entity_registry.async_update_entity(satellite.entity_id, area_id=area_1.id)
+ device_registry.async_update_device(satellite_device.id, area_id=area_1.id)
+
+ # 4. Check device area instead of entity area
+ light_device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections=set(),
+ identifiers={("demo", "id-5678")},
+ )
+ device_registry.async_update_device(light_device.id, area_id=area_1.id)
+ light_2 = entity_registry.async_update_entity(
+ light_2.entity_id, area_id=None, device_id=light_device.id
+ )
+
+ _reset()
+ await _run("turn on the lights")
+
+ # Acknowledgment sound should be played (same area)
+ text_to_speech.assert_called_once()
+ assert (
+ text_to_speech.call_args.kwargs["override_media_path"] == ACKNOWLEDGE_PATH
+ )
+ assert len(turn_on) == 2
+
+ # 5. Move device to different area
+ device_registry.async_update_device(light_device.id, area_id=area_2.id)
+
+ _reset()
+ await _run("turn on light 2")
+
+ # Acknowledgment sound should be not played (different device area)
+ text_to_speech.assert_called_once()
+ assert text_to_speech.call_args.kwargs.get("override_media_path") is None
+ assert len(turn_on) == 1
+
+ # 6. No device or area
+ light_2 = entity_registry.async_update_entity(
+ light_2.entity_id, area_id=None, device_id=None
+ )
+
+ _reset()
+ await _run("turn on light 2")
+
+ # Acknowledgment sound should be not played (no area)
+ text_to_speech.assert_called_once()
+ assert text_to_speech.call_args.kwargs.get("override_media_path") is None
+ assert len(turn_on) == 1
+
+ # 7. Not in entity registry
+ hass.states.async_set("light.light_3", "off", {ATTR_FRIENDLY_NAME: "light 3"})
+
+ _reset()
+ await _run("turn on light 3")
+
+ # Acknowledgment sound should be not played (not in entity registry)
+ text_to_speech.assert_called_once()
+ assert text_to_speech.call_args.kwargs.get("override_media_path") is None
+ assert len(turn_on) == 1
+
+ # Check TTS event
+ events.clear()
+ await _run("turn on light 1")
+
+ has_acknowledge_override: bool | None = None
+ for event in events:
+ if event.type == PipelineEventType.TTS_START:
+ assert event.data
+ has_acknowledge_override = event.data["acknowledge_override"]
+ break
+
+ assert has_acknowledge_override
+
+
+async def test_acknowledge_other_agents(
+ hass: HomeAssistant,
+ init_components,
+ pipeline_data: assist_pipeline.pipeline.PipelineData,
+ mock_chat_session: chat_session.ChatSession,
+ entity_registry: er.EntityRegistry,
+ area_registry: ar.AreaRegistry,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Test that acknowledge sound is only played when intents are processed locally for other agents."""
+ area_1 = area_registry.async_get_or_create("area_1")
+
+ light_1 = entity_registry.async_get_or_create("light", "demo", "1234")
+ hass.states.async_set(light_1.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 1"})
+ light_1 = entity_registry.async_update_entity(light_1.entity_id, area_id=area_1.id)
+
+ light_2 = entity_registry.async_get_or_create("light", "demo", "5678")
+ hass.states.async_set(light_2.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 2"})
+ light_2 = entity_registry.async_update_entity(light_2.entity_id, area_id=area_1.id)
+
+ entry = MockConfigEntry()
+ entry.add_to_hass(hass)
+ satellite = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections=set(),
+ identifiers={("demo", "id-1234")},
+ )
+ device_registry.async_update_device(satellite.id, area_id=area_1.id)
+
+ events: list[assist_pipeline.PipelineEvent] = []
+ async_mock_service(hass, "light", "turn_on")
+
+ pipeline_store = pipeline_data.pipeline_store
+ pipeline = await pipeline_store.async_create_item(
+ {
+ "name": "Test 1",
+ "language": "en-US",
+ "conversation_engine": "test agent",
+ "conversation_language": "en-US",
+ "tts_engine": "test tts",
+ "tts_language": "en-US",
+ "tts_voice": "test voice",
+ "stt_engine": "test stt",
+ "stt_language": "en-US",
+ "wake_word_entity": None,
+ "wake_word_id": None,
+ "prefer_local_intents": True,
+ }
+ )
+
+ with (
+ patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info",
+ return_value=conversation.AgentInfo(
+ id="test-agent",
+ name="Test Agent",
+ supports_streaming=False,
+ ),
+ ),
+ patch(
+ "homeassistant.components.assist_pipeline.PipelineRun.prepare_text_to_speech"
+ ),
+ patch(
+ "homeassistant.components.assist_pipeline.PipelineRun.text_to_speech"
+ ) as text_to_speech,
+ patch(
+ "homeassistant.components.conversation.async_converse", return_value=None
+ ) as async_converse,
+ patch(
+ "homeassistant.components.assist_pipeline.PipelineRun._get_all_targets_in_satellite_area"
+ ) as get_all_targets_in_satellite_area,
+ ):
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input="turn on the lights",
+ session=mock_chat_session,
+ device_id=satellite.id,
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.TTS,
+ event_callback=events.append,
+ ),
+ )
+ await pipeline_input.validate()
+ await pipeline_input.execute()
+
+ # Processed locally
+ async_converse.assert_not_called()
+
+ # Not processed locally
+ text_to_speech.reset_mock()
+ get_all_targets_in_satellite_area.reset_mock()
+
+ pipeline_input = assist_pipeline.pipeline.PipelineInput(
+ intent_input="not processed locally",
+ session=mock_chat_session,
+ device_id=satellite.id,
+ run=assist_pipeline.pipeline.PipelineRun(
+ hass,
+ context=Context(),
+ pipeline=pipeline,
+ start_stage=assist_pipeline.PipelineStage.INTENT,
+ end_stage=assist_pipeline.PipelineStage.TTS,
+ event_callback=events.append,
+ ),
+ )
+ await pipeline_input.validate()
+ await pipeline_input.execute()
+
+ # The acknowledgment should not have even been checked for because the
+ # default agent didn't handle the intent.
+ text_to_speech.assert_not_called()
+ async_converse.assert_called_once()
+ get_all_targets_in_satellite_area.assert_not_called()
diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py
index 95c8f3dbf74..3741aa44559 100644
--- a/tests/components/asuswrt/conftest.py
+++ b/tests/components/asuswrt/conftest.py
@@ -12,6 +12,7 @@ import pytest
from .common import (
ASUSWRT_BASE,
+ HOST,
MOCK_MACS,
PROTOCOL_HTTP,
PROTOCOL_SSH,
@@ -155,6 +156,9 @@ def mock_controller_connect_http(mock_devices_http):
# Simulate connection status
instance.connected = True
+ # Set the webpanel address
+ instance.webpanel = f"http://{HOST}:80"
+
# Identity
instance.async_get_identity.return_value = AsusDevice(
mac=ROUTER_MAC_ADDR,
diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py
index 3201226afc5..77f7c3f0295 100644
--- a/tests/components/august/mocks.py
+++ b/tests/components/august/mocks.py
@@ -48,6 +48,13 @@ from tests.common import MockConfigEntry, load_fixture
USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081"
+# Default capabilities for locks
+_DEFAULT_CAPABILITIES = {
+ "unlatch": False,
+ "doorSense": True,
+ "batteryType": "AA",
+}
+
def _mock_get_config(
brand: Brand = Brand.YALE_AUGUST, jwt: str | None = None
@@ -342,6 +349,15 @@ async def make_mock_api(
api_instance.async_unlatch_async = AsyncMock()
api_instance.async_unlatch = AsyncMock()
+ # Mock capabilities endpoint
+ async def mock_get_lock_capabilities(token, serial_number):
+ """Mock the capabilities endpoint response."""
+ return {"lock": _DEFAULT_CAPABILITIES}
+
+ api_instance.async_get_lock_capabilities = AsyncMock(
+ side_effect=mock_get_lock_capabilities
+ )
+
return api_instance
diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py
index af9a2cf62f1..f7d20687c92 100644
--- a/tests/components/auth/test_login_flow.py
+++ b/tests/components/auth/test_login_flow.py
@@ -7,6 +7,7 @@ from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
+from homeassistant.core_config import async_process_ha_core_config
from . import BASE_CONFIG, async_setup_auth
@@ -371,19 +372,54 @@ async def test_login_exist_user_ip_changes(
assert response == {"message": "IP address changed"}
+@pytest.mark.usefixtures("current_request_with_host") # Has example.com host
+@pytest.mark.parametrize(
+ ("config", "expected_url_prefix"),
+ [
+ (
+ {
+ "internal_url": "http://192.168.1.100:8123",
+ # Current request matches external url
+ "external_url": "https://example.com",
+ },
+ "https://example.com",
+ ),
+ (
+ {
+ # Current request matches internal url
+ "internal_url": "https://example.com",
+ "external_url": "https://other.com",
+ },
+ "https://example.com",
+ ),
+ (
+ {
+ # Current request does not match either url
+ "internal_url": "https://other.com",
+ "external_url": "https://again.com",
+ },
+ "",
+ ),
+ ],
+ ids=["external_url", "internal_url", "no_match"],
+)
async def test_well_known_auth_info(
- hass: HomeAssistant, aiohttp_client: ClientSessionGenerator
+ hass: HomeAssistant,
+ aiohttp_client: ClientSessionGenerator,
+ config: dict[str, str],
+ expected_url_prefix: str,
) -> None:
- """Test logging in and the ip address changes results in an rejection."""
+ """Test the well-known OAuth authorization server endpoint with different URL configurations."""
+ await async_process_ha_core_config(hass, config)
client = await async_setup_auth(hass, aiohttp_client, setup_api=True)
resp = await client.get(
"/.well-known/oauth-authorization-server",
)
assert resp.status == 200
assert await resp.json() == {
- "authorization_endpoint": "/auth/authorize",
- "token_endpoint": "/auth/token",
- "revocation_endpoint": "/auth/revoke",
+ "authorization_endpoint": f"{expected_url_prefix}/auth/authorize",
+ "token_endpoint": f"{expected_url_prefix}/auth/token",
+ "revocation_endpoint": f"{expected_url_prefix}/auth/revoke",
"response_types_supported": ["code"],
"service_documentation": "https://developers.home-assistant.io/docs/auth_api",
}
diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py
index b3845b1209a..0d5bdfd6504 100644
--- a/tests/components/backup/test_http.py
+++ b/tests/components/backup/test_http.py
@@ -4,11 +4,13 @@ import asyncio
from collections.abc import AsyncIterator
from io import BytesIO, StringIO
import json
+import re
import tarfile
from typing import Any
from unittest.mock import patch
from aiohttp import web
+from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
import pytest
from homeassistant.components.backup import (
@@ -166,10 +168,19 @@ async def _test_downloading_encrypted_backup(
agent_id: str,
) -> None:
"""Test downloading an encrypted backup file."""
+
+ def assert_tar_download_response(resp: web.Response) -> None:
+ assert resp.status == 200
+ assert resp.headers.get(CONTENT_TYPE, "") == "application/x-tar"
+ assert re.match(
+ r"attachment; filename=.*\.tar", resp.headers.get(CONTENT_DISPOSITION, "")
+ )
+
# Try downloading without supplying a password
client = await hass_client()
resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}")
- assert resp.status == 200
+ assert_tar_download_response(resp)
+
backup = await resp.read()
# We expect a valid outer tar file, but the inner tar file is encrypted and
# can't be read
@@ -187,7 +198,7 @@ async def _test_downloading_encrypted_backup(
resp = await client.get(
f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong"
)
- assert resp.status == 200
+ assert_tar_download_response(resp)
backup = await resp.read()
# We expect a truncated outer tar file
with (
@@ -200,7 +211,7 @@ async def _test_downloading_encrypted_backup(
resp = await client.get(
f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2"
)
- assert resp.status == 200
+ assert_tar_download_response(resp)
backup = await resp.read()
# We expect a valid outer tar file, the inner tar file is decrypted and can be read
with (
diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py
index c7915968cbf..0ddbfcafc58 100644
--- a/tests/components/bang_olufsen/conftest.py
+++ b/tests/components/bang_olufsen/conftest.py
@@ -76,6 +76,39 @@ def mock_config_entry_core() -> MockConfigEntry:
)
+async def mock_websocket_connection(
+ hass: HomeAssistant, mock_mozart_client: AsyncMock
+) -> None:
+ """Register and receive initial WebSocket notifications."""
+
+ # Currently only add notifications that are used.
+
+ # Register callbacks.
+ volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0]
+ source_change_callback = (
+ mock_mozart_client.get_source_change_notifications.call_args[0][0]
+ )
+ playback_state_callback = (
+ mock_mozart_client.get_playback_state_notifications.call_args[0][0]
+ )
+ playback_metadata_callback = (
+ mock_mozart_client.get_playback_metadata_notifications.call_args[0][0]
+ )
+
+ # Trigger callbacks. Try to use existing data
+ volume_callback(mock_mozart_client.get_product_state.return_value.volume)
+ source_change_callback(
+ mock_mozart_client.get_product_state.return_value.playback.source
+ )
+ playback_state_callback(
+ mock_mozart_client.get_product_state.return_value.playback.state
+ )
+ playback_metadata_callback(
+ mock_mozart_client.get_product_state.return_value.playback.metadata
+ )
+ await hass.async_block_till_done()
+
+
@pytest.fixture(name="integration")
async def integration_fixture(
hass: HomeAssistant,
@@ -88,6 +121,8 @@ async def integration_fixture(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
+ await mock_websocket_connection(hass, mock_mozart_client)
+
@pytest.fixture
def mock_mozart_client() -> Generator[AsyncMock]:
diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
index bc51f89f96d..80944a7112d 100644
--- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
@@ -64,6 +64,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': 'music',
+ 'repeat': 'off',
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
index be7989a2cb9..38b2d9b4156 100644
--- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
@@ -47,7 +47,7 @@
'state': 'playing',
})
# ---
-# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-2]
+# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-3]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -96,7 +96,7 @@
'state': 'playing',
})
# ---
-# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-2]
+# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-3]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -145,7 +145,7 @@
'state': 'playing',
})
# ---
-# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-1]
+# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -194,7 +194,7 @@
'state': 'playing',
})
# ---
-# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-1]
+# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-2]
StateSnapshot({
'attributes': ReadOnlyDict({
'beolink': dict({
@@ -412,6 +412,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -458,6 +460,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -504,6 +508,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -647,6 +653,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -659,13 +667,14 @@
'HDMI A',
]),
'supported_features': ,
+ 'volume_level': 0.0,
}),
'context': ,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'playing',
+ 'state': 'idle',
})
# ---
# name: test_async_join_players[group_members1-0-1]
@@ -742,6 +751,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -754,13 +765,14 @@
'HDMI A',
]),
'supported_features': ,
+ 'volume_level': 0.0,
}),
'context': ,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'playing',
+ 'state': 'idle',
})
# ---
# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source]
@@ -789,6 +801,8 @@
]),
'media_content_type': ,
'media_position': 0,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -836,6 +850,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -848,13 +864,14 @@
'HDMI A',
]),
'supported_features': ,
+ 'volume_level': 0.0,
}),
'context': ,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'playing',
+ 'state': 'idle',
})
# ---
# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity]
@@ -882,6 +899,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -929,6 +948,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -941,13 +962,14 @@
'HDMI A',
]),
'supported_features': ,
+ 'volume_level': 0.0,
}),
'context': ,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'playing',
+ 'state': 'idle',
})
# ---
# name: test_async_unjoin_player
@@ -1021,6 +1043,8 @@
'media_player.beosound_balance_11111111',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -1067,6 +1091,8 @@
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'media_content_type': ,
+ 'repeat': ,
+ 'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
@@ -1079,12 +1105,13 @@
'HDMI A',
]),
'supported_features': ,
+ 'volume_level': 0.0,
}),
'context': ,
'entity_id': 'media_player.beoconnect_core_22222222',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'playing',
+ 'state': 'idle',
})
# ---
diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py
index efa5a0a8680..9b74963ef2d 100644
--- a/tests/components/bang_olufsen/test_diagnostics.py
+++ b/tests/components/bang_olufsen/test_diagnostics.py
@@ -6,9 +6,10 @@ from syrupy.filters import props
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
+from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
-from tests.common import MockConfigEntry
+from tests.common import AsyncMock, MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@@ -19,6 +20,7 @@ async def test_async_get_config_entry_diagnostics(
hass_client: ClientSessionGenerator,
integration: None,
mock_config_entry: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
@@ -27,6 +29,9 @@ async def test_async_get_config_entry_diagnostics(
entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
+ # Re-trigger WebSocket events after the reload
+ await mock_websocket_connection(hass, mock_mozart_client)
+
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py
index 1e5546ac5f2..bb9c7389333 100644
--- a/tests/components/bang_olufsen/test_event.py
+++ b/tests/components/bang_olufsen/test_event.py
@@ -16,6 +16,7 @@ from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
+from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
from tests.common import MockConfigEntry
@@ -61,6 +62,7 @@ async def test_button_event_creation_beoconnect_core(
# Load entry
mock_config_entry_core.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_core.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
# Check number of entities
# The media_player entity should be the only available
diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py
index 33719cb2311..9c2bf99f87a 100644
--- a/tests/components/bang_olufsen/test_media_player.py
+++ b/tests/components/bang_olufsen/test_media_player.py
@@ -76,6 +76,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.setup import async_setup_component
+from .conftest import mock_websocket_connection
from .const import (
TEST_ACTIVE_SOUND_MODE_NAME,
TEST_ACTIVE_SOUND_MODE_NAME_2,
@@ -126,12 +127,12 @@ async def test_initialization(
mock_mozart_client: AsyncMock,
) -> None:
"""Test the integration is initialized properly in _initialize, async_added_to_hass and __init__."""
-
caplog.set_level(logging.DEBUG)
# Setup entity
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
# Ensure that the logger has been called with the debug message
assert "Connected to: Beosound Balance 11111111 running SW 1.0.0" in caplog.text
@@ -145,14 +146,13 @@ async def test_initialization(
# Check API calls
mock_mozart_client.get_softwareupdate_status.assert_called_once()
- mock_mozart_client.get_product_state.assert_called_once()
mock_mozart_client.get_available_sources.assert_called_once()
mock_mozart_client.get_remote_menu.assert_called_once()
mock_mozart_client.get_listening_mode_set.assert_called_once()
mock_mozart_client.get_active_listening_mode.assert_called_once()
mock_mozart_client.get_beolink_self.assert_called_once()
- mock_mozart_client.get_beolink_peers.assert_called_once()
- mock_mozart_client.get_beolink_listeners.assert_called_once()
+ assert mock_mozart_client.get_beolink_peers.call_count == 2
+ assert mock_mozart_client.get_beolink_listeners.call_count == 2
async def test_async_update_sources_audio_only(
@@ -165,6 +165,7 @@ async def test_async_update_sources_audio_only(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_AUDIO_SOURCES
@@ -180,6 +181,7 @@ async def test_async_update_sources_outdated_api(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert (
@@ -194,7 +196,6 @@ async def test_async_update_sources_remote(
mock_mozart_client: AsyncMock,
) -> None:
"""Test _async_update_sources is called when there are new video sources."""
-
notification_callback = mock_mozart_client.get_notification_notifications.call_args[
0
][0]
@@ -221,6 +222,7 @@ async def test_async_update_sources_availability(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
playback_source_callback = (
mock_mozart_client.get_playback_source_notifications.call_args[0][0]
@@ -408,7 +410,6 @@ async def test_async_turn_off(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_turn_off."""
-
playback_state_callback = (
mock_mozart_client.get_playback_state_notifications.call_args[0][0]
)
@@ -475,6 +476,7 @@ async def test_async_update_beolink_line_in(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
source_change_callback = (
mock_mozart_client.get_source_change_notifications.call_args[0][0]
@@ -488,9 +490,9 @@ async def test_async_update_beolink_line_in(
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes["group_members"] == []
- # Called once during _initialize and once during _async_update_beolink
- assert mock_mozart_client.get_beolink_listeners.call_count == 2
- assert mock_mozart_client.get_beolink_peers.call_count == 2
+ # Called twice during _initialize and once during WebSocket connection
+ assert mock_mozart_client.get_beolink_listeners.call_count == 3
+ assert mock_mozart_client.get_beolink_peers.call_count == 3
async def test_async_update_beolink_listener(
@@ -525,10 +527,10 @@ async def test_async_update_beolink_listener(
]
# Called once for each entity during _initialize
- assert mock_mozart_client.get_beolink_listeners.call_count == 2
+ assert mock_mozart_client.get_beolink_listeners.call_count == 3
# Called once for each entity during _initialize and
# once more during _async_update_beolink for the entity that has the callback associated with it.
- assert mock_mozart_client.get_beolink_peers.call_count == 3
+ assert mock_mozart_client.get_beolink_peers.call_count == 4
# Main entity
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
@@ -553,6 +555,7 @@ async def test_async_update_name_and_beolink(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await mock_websocket_connection(hass, mock_mozart_client)
configuration_callback = (
mock_mozart_client.get_notification_notifications.call_args[0][0]
@@ -563,8 +566,8 @@ async def test_async_update_name_and_beolink(
await hass.async_block_till_done()
assert mock_mozart_client.get_beolink_self.call_count == 2
- assert mock_mozart_client.get_beolink_peers.call_count == 2
- assert mock_mozart_client.get_beolink_listeners.call_count == 2
+ assert mock_mozart_client.get_beolink_peers.call_count == 3
+ assert mock_mozart_client.get_beolink_listeners.call_count == 3
# Check that device name has been changed
assert mock_config_entry.unique_id
@@ -841,7 +844,6 @@ async def test_async_select_sound_mode_invalid(
integration: None,
) -> None:
"""Test async_select_sound_mode with an invalid sound_mode."""
-
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -863,7 +865,6 @@ async def test_async_play_media_invalid_type(
integration: None,
) -> None:
"""Test async_play_media only accepts valid media types."""
-
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -910,7 +911,6 @@ async def test_async_play_media_overlay_absolute_volume_uri(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media overlay with Home Assistant local URI and absolute volume."""
-
await async_setup_component(hass, "media_source", {"media_source": {}})
await hass.services.async_call(
@@ -1062,7 +1062,6 @@ async def test_async_play_media_deezer_flow(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media with Deezer flow."""
-
# Send a service call
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
@@ -1132,7 +1131,6 @@ async def test_async_play_media_invalid_deezer(
mock_mozart_client: AsyncMock,
) -> None:
"""Test async_play_media with an invalid/no Deezer login."""
-
mock_mozart_client.start_deezer_flow.side_effect = TEST_DEEZER_INVALID_FLOW
with pytest.raises(HomeAssistantError) as exc_info:
@@ -1231,7 +1229,6 @@ async def test_async_browse_media(
present: bool,
) -> None:
"""Test async_browse_media with audio and video source."""
-
await async_setup_component(hass, "media_source", {"media_source": {}})
client = await hass_ws_client()
@@ -1489,18 +1486,18 @@ async def test_async_beolink_join_invalid(
[
# All discovered
# Valid peers
- ("all_discovered", True, None, [], 2),
+ ("all_discovered", True, None, [], 3),
# Invalid peers
(
"all_discovered",
True,
NotFoundException(),
[f"Unable to expand to {TEST_JID_3}", f"Unable to expand to {TEST_JID_4}"],
- 2,
+ 3,
),
# Beolink JIDs
# Valid peer
- ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 1),
+ ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 2),
# Invalid peer
(
"beolink_jids",
@@ -1510,7 +1507,7 @@ async def test_async_beolink_join_invalid(
f"Unable to expand to {TEST_JID_3}. Is the device available on the network?",
f"Unable to expand to {TEST_JID_4}. Is the device available on the network?",
],
- 1,
+ 2,
),
],
)
@@ -1622,9 +1619,8 @@ async def test_async_set_repeat(
repeat: RepeatMode,
) -> None:
"""Test async_set_repeat."""
-
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
- assert ATTR_MEDIA_REPEAT not in states.attributes
+ assert states.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.OFF
# Set the return value of the repeat endpoint to match service call
mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings(
@@ -1668,7 +1664,7 @@ async def test_async_set_shuffle(
) -> None:
"""Test async_set_shuffle."""
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
- assert ATTR_MEDIA_SHUFFLE not in states.attributes
+ assert states.attributes[ATTR_MEDIA_SHUFFLE] is False
# Set the return value of the shuffle endpoint to match service call
mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings(
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
index a8723ae5d30..b7b3d24c6e4 100644
--- a/tests/components/bayesian/test_binary_sensor.py
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -7,11 +7,16 @@ from unittest.mock import patch
import pytest
from homeassistant import config as hass_config
-from homeassistant.components.bayesian import DOMAIN, binary_sensor as bayesian
+from homeassistant.components.bayesian import binary_sensor as bayesian
+from homeassistant.components.bayesian.const import (
+ DEFAULT_PROBABILITY_THRESHOLD,
+ DOMAIN,
+)
from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
+from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_RELOAD,
@@ -25,7 +30,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.setup import async_setup_component
-from tests.common import get_fixture_path
+from tests.common import MockConfigEntry, get_fixture_path
async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None:
@@ -130,7 +135,64 @@ async def test_sensor_numeric_state(
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_sensor_numeric_state(hass, issue_registry)
+
+async def test_sensor_numeric_state_config_entry(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Test sensor on template platform observations."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.5,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_monitored",
+ "below": 10,
+ "above": 5,
+ "prob_given_true": 0.7,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_monitored1",
+ "below": 7,
+ "above": 5,
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.2,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ],
+ title="Test_Binary",
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_sensor_numeric_state(hass, issue_registry)
+
+
+async def _test_sensor_numeric_state(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
hass.states.async_set("sensor.test_monitored", 6)
await hass.async_block_till_done()
@@ -223,6 +285,47 @@ async def test_sensor_state(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_sensor_state(hass, prior)
+
+
+async def test_sensor_state_config_entry(hass: HomeAssistant) -> None:
+ """Test sensor on template platform observations."""
+ prior = 0.2
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": prior,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ title="Test_Binary",
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_sensor_state(hass, prior)
+
+
+async def _test_sensor_state(hass: HomeAssistant, prior: float) -> None:
+ """Common test code for state-based observations."""
hass.states.async_set("sensor.test_monitored", "on")
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.test_binary")
@@ -294,6 +397,44 @@ async def test_sensor_value_template(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_sensor_value_template(hass)
+
+
+async def test_sensor_value_template_config_entry(hass: HomeAssistant) -> None:
+ """Test sensor on template platform observations."""
+ template_config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored') == 'off'}}",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ title="Test_Binary",
+ )
+ template_config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(template_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_sensor_value_template(hass)
+
+
+async def _test_sensor_value_template(hass: HomeAssistant) -> None:
hass.states.async_set("sensor.test_monitored", "on")
state = hass.states.get("binary_sensor.test_binary")
@@ -360,7 +501,71 @@ async def test_mixed_states(hass: HomeAssistant) -> None:
}
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_mixed_states(hass)
+
+async def test_mixed_states_config_entry(hass: HomeAssistant) -> None:
+ """Test sensor on template platform observations."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "should_HVAC",
+ "prior": 0.3,
+ "probability_threshold": 0.5,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{states('sensor.guest_sensor') != 'off'}}",
+ "prob_given_true": 0.3,
+ "prob_given_false": 0.15,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.anyone_home",
+ "to_state": "on",
+ "prob_given_true": 0.6,
+ "prob_given_false": 0.05,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.temperature",
+ "below": 24,
+ "above": 19,
+ "prob_given_true": 0.1,
+ "prob_given_false": 0.6,
+ "name": "observation_3",
+ },
+ subentry_type="observation",
+ title="observation_3",
+ unique_id=None,
+ ),
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_mixed_states(hass)
+
+
+async def _test_mixed_states(hass: HomeAssistant) -> None:
+ """Common test code for mixed states."""
hass.states.async_set("sensor.guest_sensor", "UNKNOWN")
hass.states.async_set("sensor.anyone_home", "on")
hass.states.async_set("sensor.temperature", 15)
@@ -416,7 +621,49 @@ async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry)
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_threshold(hass, issue_registry)
+
+async def test_threshold_config_entry(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Test sensor on template platform observations."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.5,
+ "probability_threshold": 1,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "on",
+ "prob_given_true": 1.0,
+ "prob_given_false": 0.0,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_threshold(hass, issue_registry)
+
+
+async def _test_threshold(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Common test code for threshold testing."""
hass.states.async_set("sensor.test_monitored", "on")
await hass.async_block_till_done()
@@ -434,7 +681,7 @@ async def test_multiple_observations(hass: HomeAssistant) -> None:
Before the merge of #67631 this practice was a common work-around for bayesian's ignoring of negative observations,
this also preserves that function
"""
-
+ prior = 0.2
config = {
"binary_sensor": {
"name": "Test_Binary",
@@ -455,14 +702,66 @@ async def test_multiple_observations(hass: HomeAssistant) -> None:
"prob_given_false": 0.6,
},
],
- "prior": 0.2,
+ "prior": prior,
"probability_threshold": 0.32,
}
}
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_multiple_observations(hass, prior)
+
+async def test_multiple_observations_config_entry(hass: HomeAssistant) -> None:
+ """Test sensor on multiple observations."""
+ prior = 0.2
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": prior,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "blue",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "red",
+ "prob_given_true": 0.2,
+ "prob_given_false": 0.6,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_multiple_observations(hass, prior)
+
+
+async def _test_multiple_observations(hass: HomeAssistant, prior: float) -> None:
+ """Common test code for multiple observations."""
hass.states.async_set("sensor.test_monitored", "off")
await hass.async_block_till_done()
@@ -471,7 +770,7 @@ async def test_multiple_observations(hass: HomeAssistant) -> None:
for attrs in state.attributes.values():
json.dumps(attrs)
assert state.attributes.get("occurred_observation_entities") == []
- assert state.attributes.get("probability") == 0.2
+ assert state.attributes.get("probability") == prior
# probability should be the same as the prior as negative observations are ignored in multi-state
assert state.state == "off"
@@ -564,7 +863,104 @@ async def test_multiple_numeric_observations(
}
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_multiple_numeric_observations(hass, issue_registry)
+
+async def test_multiple_numeric_observations_config_entry(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Test sensor on multiple numeric state observations."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "nice_day",
+ "prior": 0.3,
+ "probability_threshold": DEFAULT_PROBABILITY_THRESHOLD,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_temp",
+ "below": 0,
+ "prob_given_true": 0.05,
+ "prob_given_false": 0.2,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_temp",
+ "below": 10,
+ "above": 0,
+ "prob_given_true": 0.1,
+ "prob_given_false": 0.25,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_temp",
+ "below": 15,
+ "above": 10,
+ "prob_given_true": 0.2,
+ "prob_given_false": 0.35,
+ "name": "observation_3",
+ },
+ subentry_type="observation",
+ title="observation_3",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_temp",
+ "below": 25,
+ "above": 15,
+ "prob_given_true": 0.5,
+ "prob_given_false": 0.15,
+ "name": "observation_4",
+ },
+ subentry_type="observation",
+ title="observation_4",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_temp",
+ "above": 25,
+ "prob_given_true": 0.15,
+ "prob_given_false": 0.05,
+ "name": "observation_5",
+ },
+ subentry_type="observation",
+ title="observation_5",
+ unique_id=None,
+ ),
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_multiple_numeric_observations(hass, issue_registry)
+
+
+async def _test_multiple_numeric_observations(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Common test code for multiple numeric state observations."""
hass.states.async_set("sensor.test_temp", -5)
await hass.async_block_till_done()
@@ -776,6 +1172,152 @@ async def test_mirrored_observations(
assert len(issue_registry.issues) == 0
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+
+ await _test_mirrored_observations(hass, issue_registry)
+
+
+async def test_mirrored_observations_config_entry(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Test sensor on legacy mirrored observations."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.1,
+ "probability_threshold": DEFAULT_PROBABILITY_THRESHOLD,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "binary_sensor.test_monitored",
+ "to_state": "on",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "binary_sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.2,
+ "prob_given_false": 0.59,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_monitored1",
+ "above": 5,
+ "prob_given_true": 0.7,
+ "prob_given_false": 0.4,
+ "name": "observation_3",
+ },
+ subentry_type="observation",
+ title="observation_3",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "numeric_state",
+ "entity_id": "sensor.test_monitored1",
+ "below": 5,
+ "prob_given_true": 0.3,
+ "prob_given_false": 0.6,
+ "name": "observation_4",
+ },
+ subentry_type="observation",
+ title="observation_4",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored2') == 'off'}}",
+ "prob_given_true": 0.79,
+ "prob_given_false": 0.4,
+ "name": "observation_5",
+ },
+ subentry_type="observation",
+ title="observation_5",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored2') == 'on'}}",
+ "prob_given_true": 0.2,
+ "prob_given_false": 0.6,
+ "name": "observation_6",
+ },
+ subentry_type="observation",
+ title="observation_6",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.colour",
+ "to_state": "blue",
+ "prob_given_true": 0.33,
+ "prob_given_false": 0.8,
+ "name": "observation_7",
+ },
+ subentry_type="observation",
+ title="observation_7",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.colour",
+ "to_state": "green",
+ "prob_given_true": 0.3,
+ "prob_given_false": 0.15,
+ "name": "observation_8",
+ },
+ subentry_type="observation",
+ title="observation_8",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.colour",
+ "to_state": "red",
+ "prob_given_true": 0.4,
+ "prob_given_false": 0.05,
+ "name": "observation_9",
+ },
+ subentry_type="observation",
+ title="observation_9",
+ unique_id=None,
+ ),
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_mirrored_observations(hass, issue_registry)
+
+
+async def _test_mirrored_observations(
+ hass: HomeAssistant, issue_registry: ir.IssueRegistry
+) -> None:
+ """Common test code for mirrored observations."""
hass.states.async_set("sensor.test_monitored2", "on")
await hass.async_block_till_done()
@@ -791,7 +1333,7 @@ async def test_mirrored_observations(
async def test_missing_prob_given_false(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
- """Test whether missing prob_given_false are detected and appropriate issues are created."""
+ """Test whether missing prob_given_false in YAML are detected and appropriate issues are created."""
config = {
"binary_sensor": {
@@ -839,7 +1381,7 @@ async def test_bad_multi_numeric(
issue_registry: ir.IssueRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
- """Test whether missing prob_given_false are detected and appropriate issues are created."""
+ """Test whether overlaps are detected in YAML configs, in Config Entries this is detected during the config flow and is tested elsewhere."""
config = {
"binary_sensor": {
@@ -901,7 +1443,7 @@ async def test_inverted_numeric(
issue_registry: ir.IssueRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
- """Test whether missing prob_given_false are detected and appropriate logs are created."""
+ """Test whether inverted numeric states are detected in YAML configs, for config entries this is detected during config flow validation and so is tested elsewhere."""
config = {
"binary_sensor": {
@@ -933,7 +1475,7 @@ async def test_no_value_numeric(
issue_registry: ir.IssueRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
- """Test whether missing prob_given_false are detected and appropriate logs are created."""
+ """Tests whether numeric states with no above or below are detected in YAML configs, for config entries this is detected during config flow validation and so is tested elsewhere."""
config = {
"binary_sensor": {
@@ -977,7 +1519,7 @@ async def test_probability_updates(hass: HomeAssistant) -> None:
async def test_observed_entities(hass: HomeAssistant) -> None:
- """Test sensor on observed entities."""
+ """Test the observation attributes."""
config = {
"binary_sensor": {
"name": "Test_Binary",
@@ -1008,6 +1550,63 @@ async def test_observed_entities(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_observed_entities(
+ hass,
+ )
+
+
+async def test_observed_entities_config_entry(hass: HomeAssistant) -> None:
+ """Test the observation attributes using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ ),
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": (
+ "{{is_state('sensor.test_monitored1','on') and"
+ " is_state('sensor.test_monitored','off')}}"
+ ),
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.1,
+ "name": "observation_2",
+ },
+ subentry_type="observation",
+ title="observation_2",
+ unique_id=None,
+ ),
+ ],
+ title="Test_Binary",
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_observed_entities(hass)
+
+
+async def _test_observed_entities(hass: HomeAssistant) -> None:
+ """Common test code for occurred_observation_entities. This test reveals some interesting historic behaviour - the last entity to update a template is the one that is recorded as having made the observation."""
hass.states.async_set("sensor.test_monitored", "on")
await hass.async_block_till_done()
hass.states.async_set("sensor.test_monitored1", "off")
@@ -1123,6 +1722,48 @@ async def test_template_error(
await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_template_error(hass, caplog)
+
+
+async def test_template_error_config_entry(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test template sensor with template error using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{ xyz + 1 }}",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.1,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_template_error(hass, caplog)
+
+
+async def _test_template_error(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Common test code for template error."""
assert hass.states.get("binary_sensor.test_binary").state == "off"
assert "TemplateError" in caplog.text
@@ -1149,6 +1790,45 @@ async def test_update_request_with_template(hass: HomeAssistant) -> None:
}
await async_setup_component(hass, "binary_sensor", config)
+
+ await _test_update_request_with_template(hass)
+
+
+async def test_update_request_with_template_config_entry(hass: HomeAssistant) -> None:
+ """Test template sensor with template error using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored') == 'off'}}",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_update_request_with_template(hass)
+
+
+async def _test_update_request_with_template(hass: HomeAssistant) -> None:
+ """Common test code for template update."""
await async_setup_component(hass, HA_DOMAIN, {})
await hass.async_block_till_done()
@@ -1166,7 +1846,7 @@ async def test_update_request_with_template(hass: HomeAssistant) -> None:
async def test_update_request_without_template(hass: HomeAssistant) -> None:
- """Test sensor on template platform observations that gets an update request."""
+ """Test sensor on state platform observations that gets an update request."""
config = {
"binary_sensor": {
"name": "Test_Binary",
@@ -1186,6 +1866,48 @@ async def test_update_request_without_template(hass: HomeAssistant) -> None:
}
await async_setup_component(hass, "binary_sensor", config)
+
+ await _test_update_request_without_template(hass)
+
+
+async def test_update_request_without_template_config_entry(
+ hass: HomeAssistant,
+) -> None:
+ """Test template sensor with template error using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_update_request_without_template(hass)
+
+
+async def _test_update_request_without_template(hass: HomeAssistant) -> None:
+ """Common test code for state update."""
await async_setup_component(hass, HA_DOMAIN, {})
await hass.async_block_till_done()
@@ -1206,7 +1928,8 @@ async def test_update_request_without_template(hass: HomeAssistant) -> None:
async def test_monitored_sensor_goes_away(hass: HomeAssistant) -> None:
- """Test sensor on template platform observations that goes away."""
+ """Test sensor on state platform observations that goes away."""
+ prior = 0.2
config = {
"binary_sensor": {
"name": "Test_Binary",
@@ -1220,12 +1943,56 @@ async def test_monitored_sensor_goes_away(hass: HomeAssistant) -> None:
"prob_given_false": 0.4,
},
],
- "prior": 0.2,
+ "prior": prior,
"probability_threshold": 0.32,
}
}
await async_setup_component(hass, "binary_sensor", config)
+
+ await _test_monitored_sensor_goes_away(hass, prior)
+
+
+async def test_monitored_sensor_goes_away_config_entry(
+ hass: HomeAssistant,
+) -> None:
+ """Test template sensor with template error using config entry."""
+ prior = 0.2
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": prior,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "on",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_monitored_sensor_goes_away(hass, prior)
+
+
+async def _test_monitored_sensor_goes_away(hass: HomeAssistant, prior: float) -> None:
+ """Common test code for state update."""
+
await async_setup_component(hass, HA_DOMAIN, {})
await hass.async_block_till_done()
@@ -1237,17 +2004,24 @@ async def test_monitored_sensor_goes_away(hass: HomeAssistant) -> None:
# Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.9, P(B|notA) = 0.4 -> 0.36 (>0.32)
hass.states.async_remove("sensor.test_monitored")
-
await hass.async_block_till_done()
+
assert (
hass.states.get("binary_sensor.test_binary").attributes.get("probability")
- == 0.2
+ == prior
+ )
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+ hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE)
+ assert (
+ hass.states.get("binary_sensor.test_binary").attributes.get("probability")
+ == prior
)
assert hass.states.get("binary_sensor.test_binary").state == "off"
async def test_reload(hass: HomeAssistant) -> None:
- """Verify we can reload bayesian sensors."""
+ """Verify we can reload YAML bayesian sensors."""
config = {
"binary_sensor": {
@@ -1314,6 +2088,47 @@ async def test_template_triggers(hass: HomeAssistant) -> None:
await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_template_triggers(hass)
+
+
+async def test_template_triggers_config_entry(
+ hass: HomeAssistant,
+) -> None:
+ """Test template sensor with template error using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "template",
+ "value_template": "{{ states.input_boolean.test.state }}",
+ "prob_given_true": 1.0,
+ "prob_given_false": 0.0,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_template_triggers(hass)
+
+
+async def _test_template_triggers(hass: HomeAssistant) -> None:
+ """Common test code for template triggers."""
+
assert hass.states.get("binary_sensor.test_binary").state == STATE_OFF
events = []
@@ -1356,6 +2171,46 @@ async def test_state_triggers(hass: HomeAssistant) -> None:
await async_setup_component(hass, "binary_sensor", config)
await hass.async_block_till_done()
+ await _test_state_triggers(hass)
+
+
+async def test_state_triggers_config_entry(
+ hass: HomeAssistant,
+) -> None:
+ """Test template sensor with template error using config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "Test_Binary",
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ },
+ subentries_data=[
+ ConfigSubentryData(
+ data={
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.9999,
+ "prob_given_false": 0.9994,
+ "name": "observation_1",
+ },
+ subentry_type="observation",
+ title="observation_1",
+ unique_id=None,
+ )
+ ],
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await _test_state_triggers(hass)
+
+
+async def _test_state_triggers(hass: HomeAssistant) -> None:
assert hass.states.get("binary_sensor.test_binary").state == STATE_OFF
events = []
diff --git a/tests/components/bayesian/test_config_flow.py b/tests/components/bayesian/test_config_flow.py
new file mode 100644
index 00000000000..0911113a22a
--- /dev/null
+++ b/tests/components/bayesian/test_config_flow.py
@@ -0,0 +1,1211 @@
+"""Test the Config flow for the Bayesian integration."""
+
+from __future__ import annotations
+
+from types import MappingProxyType
+from unittest.mock import patch
+
+import pytest
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.bayesian.config_flow import (
+ OBSERVATION_SELECTOR,
+ USER,
+ ObservationTypes,
+ OptionsFlowSteps,
+)
+from homeassistant.components.bayesian.const import (
+ CONF_P_GIVEN_F,
+ CONF_P_GIVEN_T,
+ CONF_PRIOR,
+ CONF_PROBABILITY_THRESHOLD,
+ CONF_TO_STATE,
+ DOMAIN,
+)
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigSubentry,
+ ConfigSubentryDataWithId,
+)
+from homeassistant.const import (
+ CONF_ABOVE,
+ CONF_BELOW,
+ CONF_DEVICE_CLASS,
+ CONF_ENTITY_ID,
+ CONF_NAME,
+ CONF_PLATFORM,
+ CONF_STATE,
+ CONF_VALUE_TEMPLATE,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from tests.common import MockConfigEntry
+
+
+async def test_config_flow_step_user(hass: HomeAssistant) -> None:
+ """Test the config flow with an example."""
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ):
+ # Open config flow
+ result0 = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result0["step_id"] == USER
+ assert result0["type"] is FlowResultType.FORM
+ assert (
+ result0["description_placeholders"]["url"]
+ == "https://www.home-assistant.io/integrations/bayesian/"
+ )
+
+ # Enter basic settings
+ result1 = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 15,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # We move on to the next step - the observation selector
+ assert result1["step_id"] == OBSERVATION_SELECTOR
+ assert result1["type"] is FlowResultType.MENU
+ assert result1["flow_id"] is not None
+
+
+async def test_subentry_flow(hass: HomeAssistant) -> None:
+ """Test the subentry flow with a full example."""
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ # Set up the initial config entry as a mock to isolate testing of subentry flows
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 15,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ )
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Open subentry flow
+ result = await hass.config_entries.subentries.async_init(
+ (config_entry.entry_id, "observation"),
+ context={"source": config_entries.SOURCE_USER},
+ )
+ # Confirm the next page is the observation type selector
+ assert result["step_id"] == "user"
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # Set up a numeric state observation first
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE)
+ assert result["type"] is FlowResultType.FORM
+
+ # Set up a numeric range with only 'Above'
+ # Also indirectly tests the conversion of proabilities to fractions
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 85,
+ CONF_P_GIVEN_F: 45,
+ CONF_NAME: "Office is bright",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Open another subentry flow
+ result = await hass.config_entries.subentries.async_init(
+ (config_entry.entry_id, "observation"),
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["step_id"] == "user"
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # Add a state observation
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.STATE)
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 60,
+ CONF_P_GIVEN_F: 20,
+ CONF_NAME: "Work laptop on network",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Open another subentry flow
+ result = await hass.config_entries.subentries.async_init(
+ (config_entry.entry_id, "observation"),
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["step_id"] == "user"
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # Lastly, add a template observation
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.TEMPLATE)
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_VALUE_TEMPLATE: """
+{% set current_time = now().time() %}
+{% set start_time = strptime("07:00", "%H:%M").time() %}
+{% set end_time = strptime("18:30", "%H:%M").time() %}
+{% if start_time <= current_time <= end_time %}
+True
+{% else %}
+False
+{% endif %}
+ """,
+ CONF_P_GIVEN_T: 45,
+ CONF_P_GIVEN_F: 5,
+ CONF_NAME: "Daylight hours",
+ },
+ )
+
+ observations = [
+ dict(subentry.data) for subentry in config_entry.subentries.values()
+ ]
+ # assert config_entry["version"] == 1
+ assert observations == [
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 0.85,
+ CONF_P_GIVEN_F: 0.45,
+ CONF_NAME: "Office is bright",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.STATE),
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.6,
+ CONF_P_GIVEN_F: 0.2,
+ CONF_NAME: "Work laptop on network",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}',
+ CONF_P_GIVEN_T: 0.45,
+ CONF_P_GIVEN_F: 0.05,
+ CONF_NAME: "Daylight hours",
+ },
+ ]
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_single_state_observation(hass: HomeAssistant) -> None:
+ """Test a Bayesian sensor with just one state observation added.
+
+ This test combines the config flow for a single state observation.
+ """
+
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["step_id"] == USER
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_NAME: "Anyone home",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 66,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+ assert result["menu_options"] == ["state", "numeric_state", "template"]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.STATE)
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.kitchen_occupancy",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 40,
+ CONF_P_GIVEN_F: 0.5,
+ CONF_NAME: "Kitchen Motion",
+ },
+ )
+
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+ assert result["menu_options"] == [
+ "state",
+ "numeric_state",
+ "template",
+ "finish",
+ ]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": "finish"}
+ )
+ await hass.async_block_till_done()
+
+ entry_id = result["result"].entry_id
+ config_entry = hass.config_entries.async_get_entry(entry_id)
+ assert config_entry is not None
+ assert type(config_entry) is ConfigEntry
+ assert config_entry.version == 1
+ assert config_entry.options == {
+ CONF_NAME: "Anyone home",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.66,
+ CONF_DEVICE_CLASS: "occupancy",
+ }
+ assert len(config_entry.subentries) == 1
+ assert list(config_entry.subentries.values())[0].data == {
+ CONF_PLATFORM: CONF_STATE,
+ CONF_ENTITY_ID: "sensor.kitchen_occupancy",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.4,
+ CONF_P_GIVEN_F: 0.005,
+ CONF_NAME: "Kitchen Motion",
+ }
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_single_numeric_state_observation(hass: HomeAssistant) -> None:
+ """Test a Bayesian sensor with just one numeric_state observation added.
+
+ Combines the config flow and the options flow for a single numeric_state observation.
+ """
+
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["step_id"] == USER
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_NAME: "Nice day",
+ CONF_PROBABILITY_THRESHOLD: 51,
+ CONF_PRIOR: 20,
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # select numeric state observation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE)
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 20,
+ CONF_BELOW: 35,
+ CONF_P_GIVEN_T: 95,
+ CONF_P_GIVEN_F: 8,
+ CONF_NAME: "20 - 35 outside",
+ },
+ )
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+ assert result["menu_options"] == [
+ "state",
+ "numeric_state",
+ "template",
+ "finish",
+ ]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": "finish"}
+ )
+ await hass.async_block_till_done()
+ config_entry = result["result"]
+ assert config_entry.options == {
+ CONF_NAME: "Nice day",
+ CONF_PROBABILITY_THRESHOLD: 0.51,
+ CONF_PRIOR: 0.2,
+ }
+ assert len(config_entry.subentries) == 1
+ assert list(config_entry.subentries.values())[0].data == {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 20,
+ CONF_BELOW: 35,
+ CONF_P_GIVEN_T: 0.95,
+ CONF_P_GIVEN_F: 0.08,
+ CONF_NAME: "20 - 35 outside",
+ }
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_multi_numeric_state_observation(hass: HomeAssistant) -> None:
+ """Test a Bayesian sensor with just more than one numeric_state observation added.
+
+ Technically a subset of the tests in test_config_flow() but may help to
+ narrow down errors more quickly.
+ """
+
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["step_id"] == USER
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_NAME: "Nice day",
+ CONF_PROBABILITY_THRESHOLD: 51,
+ CONF_PRIOR: 20,
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # select numeric state observation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.NUMERIC_STATE)
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 20,
+ CONF_BELOW: 35,
+ CONF_P_GIVEN_T: 95,
+ CONF_P_GIVEN_F: 8,
+ CONF_NAME: "20 - 35 outside",
+ },
+ )
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
+ )
+ await hass.async_block_till_done()
+
+ # This should fail as overlapping ranges for the same entity are not allowed
+ current_step = result["step_id"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 30,
+ CONF_BELOW: 40,
+ CONF_P_GIVEN_T: 95,
+ CONF_P_GIVEN_F: 8,
+ CONF_NAME: "30 - 40 outside",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["errors"] == {"base": "overlapping_ranges"}
+ assert result["step_id"] == current_step
+
+ # This should fail as above should always be less than below
+ current_step = result["step_id"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 40,
+ CONF_BELOW: 35,
+ CONF_P_GIVEN_T: 95,
+ CONF_P_GIVEN_F: 8,
+ CONF_NAME: "35 - 40 outside",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["step_id"] == current_step
+ assert result["errors"] == {"base": "above_below"}
+
+ # This should work
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 35,
+ CONF_BELOW: 40,
+ CONF_P_GIVEN_T: 70,
+ CONF_P_GIVEN_F: 20,
+ CONF_NAME: "35 - 40 outside",
+ },
+ )
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+ assert result["menu_options"] == [
+ "state",
+ "numeric_state",
+ "template",
+ "finish",
+ ]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": "finish"}
+ )
+ await hass.async_block_till_done()
+
+ config_entry = result["result"]
+ assert config_entry.version == 1
+ assert config_entry.options == {
+ CONF_NAME: "Nice day",
+ CONF_PROBABILITY_THRESHOLD: 0.51,
+ CONF_PRIOR: 0.2,
+ }
+ observations = [
+ dict(subentry.data) for subentry in config_entry.subentries.values()
+ ]
+ assert observations == [
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 20.0,
+ CONF_BELOW: 35.0,
+ CONF_P_GIVEN_T: 0.95,
+ CONF_P_GIVEN_F: 0.08,
+ CONF_NAME: "20 - 35 outside",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.outside_temperature",
+ CONF_ABOVE: 35.0,
+ CONF_BELOW: 40.0,
+ CONF_P_GIVEN_T: 0.7,
+ CONF_P_GIVEN_F: 0.2,
+ CONF_NAME: "35 - 40 outside",
+ },
+ ]
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_single_template_observation(hass: HomeAssistant) -> None:
+ """Test a Bayesian sensor with just one template observation added.
+
+ Technically a subset of the tests in test_config_flow() but may help to
+ narrow down errors more quickly.
+ """
+
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["step_id"] == USER
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_NAME: "Paulus Home",
+ CONF_PROBABILITY_THRESHOLD: 90,
+ CONF_PRIOR: 50,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ # Select template observation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.TEMPLATE)
+ assert result["type"] is FlowResultType.FORM
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}",
+ CONF_P_GIVEN_T: 5,
+ CONF_P_GIVEN_F: 99,
+ CONF_NAME: "Not seen in last 5 minutes",
+ },
+ )
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+ assert result["menu_options"] == [
+ "state",
+ "numeric_state",
+ "template",
+ "finish",
+ ]
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": "finish"}
+ )
+ await hass.async_block_till_done()
+ config_entry = result["result"]
+ assert config_entry.version == 1
+ assert config_entry.options == {
+ CONF_NAME: "Paulus Home",
+ CONF_PROBABILITY_THRESHOLD: 0.9,
+ CONF_PRIOR: 0.5,
+ CONF_DEVICE_CLASS: "occupancy",
+ }
+ assert len(config_entry.subentries) == 1
+ assert list(config_entry.subentries.values())[0].data == {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: "{{is_state('device_tracker.paulus','not_home') and ((as_timestamp(now()) - as_timestamp(states.device_tracker.paulus.last_changed)) > 300)}}",
+ CONF_P_GIVEN_T: 0.05,
+ CONF_P_GIVEN_F: 0.99,
+ CONF_NAME: "Not seen in last 5 minutes",
+ }
+
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_basic_options(hass: HomeAssistant) -> None:
+ """Test reconfiguring the basic options using an options flow."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.15,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ subentries_data=[
+ ConfigSubentryDataWithId(
+ data=MappingProxyType(
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 0.85,
+ CONF_P_GIVEN_F: 0.45,
+ CONF_NAME: "Office is bright",
+ }
+ ),
+ subentry_id="01JXCPHRM64Y84GQC58P5EKVHY",
+ subentry_type="observation",
+ title="Office is bright",
+ unique_id=None,
+ )
+ ],
+ title="Office occupied",
+ )
+ # Setup the mock config entry
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Give the sensor a real value
+ hass.states.async_set("sensor.office_illuminance_lux", 50)
+
+ # Start the options flow
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ # Confirm the first page is the form for editing the basic options
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == str(OptionsFlowSteps.INIT)
+
+ # Change all possible settings (name can be changed elsewhere in the UI)
+ await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ {
+ CONF_PROBABILITY_THRESHOLD: 49,
+ CONF_PRIOR: 14,
+ CONF_DEVICE_CLASS: "presence",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the changes stuck
+ assert hass.config_entries.async_get_entry(config_entry.entry_id).options == {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.49,
+ CONF_PRIOR: 0.14,
+ CONF_DEVICE_CLASS: "presence",
+ }
+ assert config_entry.subentries == {
+ "01JXCPHRM64Y84GQC58P5EKVHY": ConfigSubentry(
+ data=MappingProxyType(
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 0.85,
+ CONF_P_GIVEN_F: 0.45,
+ CONF_NAME: "Office is bright",
+ }
+ ),
+ subentry_id="01JXCPHRM64Y84GQC58P5EKVHY",
+ subentry_type="observation",
+ title="Office is bright",
+ unique_id=None,
+ )
+ }
+
+
+async def test_reconfiguring_observations(hass: HomeAssistant) -> None:
+ """Test editing observations through options flow, once of each of the 3 types."""
+ # Setup the config entry
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.15,
+ CONF_DEVICE_CLASS: "occupancy",
+ },
+ subentries_data=[
+ ConfigSubentryDataWithId(
+ data=MappingProxyType(
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 0.85,
+ CONF_P_GIVEN_F: 0.45,
+ CONF_NAME: "Office is bright",
+ }
+ ),
+ subentry_id="01JXCPHRM64Y84GQC58P5EKVHY",
+ subentry_type="observation",
+ title="Office is bright",
+ unique_id=None,
+ ),
+ ConfigSubentryDataWithId(
+ data=MappingProxyType(
+ {
+ CONF_PLATFORM: str(ObservationTypes.STATE),
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.6,
+ CONF_P_GIVEN_F: 0.2,
+ CONF_NAME: "Work laptop on network",
+ },
+ ),
+ subentry_id="13TCPHRM64Y84GQC58P5EKTHF",
+ subentry_type="observation",
+ title="Work laptop on network",
+ unique_id=None,
+ ),
+ ConfigSubentryDataWithId(
+ data=MappingProxyType(
+ {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}',
+ CONF_P_GIVEN_T: 0.45,
+ CONF_P_GIVEN_F: 0.05,
+ CONF_NAME: "Daylight hours",
+ }
+ ),
+ subentry_id="27TCPHRM64Y84GQC58P5EIES",
+ subentry_type="observation",
+ title="Daylight hours",
+ unique_id=None,
+ ),
+ ],
+ title="Office occupied",
+ )
+
+ # Set up the mock entry
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ hass.states.async_set("sensor.office_illuminance_lux", 50)
+
+ # select a subentry for reconfiguration
+ result = await config_entry.start_subentry_reconfigure_flow(
+ hass, subentry_id="13TCPHRM64Y84GQC58P5EKTHF"
+ )
+ await hass.async_block_till_done()
+
+ # confirm the first page is the form for editing the observation
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reconfigure"
+ assert result["description_placeholders"]["parent_sensor_name"] == "Office occupied"
+ assert result["description_placeholders"]["device_class_on"] == "Detected"
+ assert result["description_placeholders"]["device_class_off"] == "Clear"
+
+ # Edit all settings
+ await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.desktop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 70,
+ CONF_P_GIVEN_F: 12,
+ CONF_NAME: "Desktop on network",
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Confirm the changes to the state config
+ assert hass.config_entries.async_get_entry(config_entry.entry_id).options == {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.15,
+ CONF_DEVICE_CLASS: "occupancy",
+ }
+ observations = [
+ dict(subentry.data)
+ for subentry in hass.config_entries.async_get_entry(
+ config_entry.entry_id
+ ).subentries.values()
+ ]
+ assert observations == [
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 0.85,
+ CONF_P_GIVEN_F: 0.45,
+ CONF_NAME: "Office is bright",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.STATE),
+ CONF_ENTITY_ID: "sensor.desktop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.7,
+ CONF_P_GIVEN_F: 0.12,
+ CONF_NAME: "Desktop on network",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}',
+ CONF_P_GIVEN_T: 0.45,
+ CONF_P_GIVEN_F: 0.05,
+ CONF_NAME: "Daylight hours",
+ },
+ ]
+
+ # Next test editing a numeric_state observation
+ # select the subentry for reconfiguration
+ result = await config_entry.start_subentry_reconfigure_flow(
+ hass, subentry_id="01JXCPHRM64Y84GQC58P5EKVHY"
+ )
+ await hass.async_block_till_done()
+
+ # confirm the first page is the form for editing the observation
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reconfigure"
+
+ await hass.async_block_till_done()
+
+ # Test an invalid re-configuration
+ # This should fail as the probabilities are equal
+ current_step = result["step_id"]
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.office_illuminance_lumens",
+ CONF_ABOVE: 2000,
+ CONF_P_GIVEN_T: 80,
+ CONF_P_GIVEN_F: 80,
+ CONF_NAME: "Office is bright",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["step_id"] == current_step
+ assert result["errors"] == {"base": "equal_probabilities"}
+
+ # This should work
+ result = await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.office_illuminance_lumens",
+ CONF_ABOVE: 2000,
+ CONF_P_GIVEN_T: 80,
+ CONF_P_GIVEN_F: 40,
+ CONF_NAME: "Office is bright",
+ },
+ )
+ await hass.async_block_till_done()
+ assert "errors" not in result
+
+ # Confirm the changes to the state config
+ assert hass.config_entries.async_get_entry(config_entry.entry_id).options == {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.15,
+ CONF_DEVICE_CLASS: "occupancy",
+ }
+ observations = [
+ dict(subentry.data)
+ for subentry in hass.config_entries.async_get_entry(
+ config_entry.entry_id
+ ).subentries.values()
+ ]
+ assert observations == [
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lumens",
+ CONF_ABOVE: 2000,
+ CONF_P_GIVEN_T: 0.8,
+ CONF_P_GIVEN_F: 0.4,
+ CONF_NAME: "Office is bright",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.STATE),
+ CONF_ENTITY_ID: "sensor.desktop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.7,
+ CONF_P_GIVEN_F: 0.12,
+ CONF_NAME: "Desktop on network",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("18:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}',
+ CONF_P_GIVEN_T: 0.45,
+ CONF_P_GIVEN_F: 0.05,
+ CONF_NAME: "Daylight hours",
+ },
+ ]
+
+ # Next test editing a template observation
+ # select the subentry for reconfiguration
+ result = await config_entry.start_subentry_reconfigure_flow(
+ hass, subentry_id="27TCPHRM64Y84GQC58P5EIES"
+ )
+ await hass.async_block_till_done()
+
+ # confirm the first page is the form for editing the observation
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reconfigure"
+ await hass.async_block_till_done()
+
+ await hass.config_entries.subentries.async_configure(
+ result["flow_id"],
+ {
+ CONF_VALUE_TEMPLATE: """
+{% set current_time = now().time() %}
+{% set start_time = strptime("07:00", "%H:%M").time() %}
+{% set end_time = strptime("17:30", "%H:%M").time() %}
+{% if start_time <= current_time <= end_time %}
+True
+{% else %}
+False
+{% endif %}
+""", # changed the end_time
+ CONF_P_GIVEN_T: 55,
+ CONF_P_GIVEN_F: 13,
+ CONF_NAME: "Office hours",
+ },
+ )
+ await hass.async_block_till_done()
+ # Confirm the changes to the state config
+ assert hass.config_entries.async_get_entry(config_entry.entry_id).options == {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0.5,
+ CONF_PRIOR: 0.15,
+ CONF_DEVICE_CLASS: "occupancy",
+ }
+ observations = [
+ dict(subentry.data)
+ for subentry in hass.config_entries.async_get_entry(
+ config_entry.entry_id
+ ).subentries.values()
+ ]
+ assert observations == [
+ {
+ CONF_PLATFORM: str(ObservationTypes.NUMERIC_STATE),
+ CONF_ENTITY_ID: "sensor.office_illuminance_lumens",
+ CONF_ABOVE: 2000,
+ CONF_P_GIVEN_T: 0.8,
+ CONF_P_GIVEN_F: 0.4,
+ CONF_NAME: "Office is bright",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.STATE),
+ CONF_ENTITY_ID: "sensor.desktop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0.7,
+ CONF_P_GIVEN_F: 0.12,
+ CONF_NAME: "Desktop on network",
+ },
+ {
+ CONF_PLATFORM: str(ObservationTypes.TEMPLATE),
+ CONF_VALUE_TEMPLATE: '{% set current_time = now().time() %}\n{% set start_time = strptime("07:00", "%H:%M").time() %}\n{% set end_time = strptime("17:30", "%H:%M").time() %}\n{% if start_time <= current_time <= end_time %}\nTrue\n{% else %}\nFalse\n{% endif %}',
+ CONF_P_GIVEN_T: 0.55,
+ CONF_P_GIVEN_F: 0.13,
+ CONF_NAME: "Office hours",
+ },
+ ]
+
+
+async def test_invalid_configs(hass: HomeAssistant) -> None:
+ """Test that invalid configs are refused."""
+ with patch(
+ "homeassistant.components.bayesian.async_setup_entry", return_value=True
+ ):
+ result0 = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result0["step_id"] == USER
+ assert result0["type"] is FlowResultType.FORM
+
+ # priors should never be Zero, because then the sensor can never return 'on'
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 0,
+ },
+ )
+ assert CONF_PRIOR in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_prior_error"
+
+ # priors should never be 100% because then the sensor can never be 'off'
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 100,
+ },
+ )
+ assert CONF_PRIOR in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_prior_error"
+
+ # Threshold should never be 100% because then the sensor can never be 'on'
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 100,
+ CONF_PRIOR: 50,
+ },
+ )
+ assert CONF_PROBABILITY_THRESHOLD in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_threshold_error"
+
+ # Threshold should never be 0 because then the sensor can never be 'off'
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 0,
+ CONF_PRIOR: 50,
+ },
+ )
+ assert CONF_PROBABILITY_THRESHOLD in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_threshold_error"
+
+ # Now lets submit a valid config so we can test the observation flows
+ result = await hass.config_entries.flow.async_configure(
+ result0["flow_id"],
+ {
+ CONF_NAME: "Office occupied",
+ CONF_PROBABILITY_THRESHOLD: 50,
+ CONF_PRIOR: 30,
+ },
+ )
+ await hass.async_block_till_done()
+ assert result.get("errors") is None
+
+ # Confirm the next step is the menu
+ assert result["step_id"] == OBSERVATION_SELECTOR
+ assert result["type"] is FlowResultType.MENU
+ assert result["flow_id"] is not None
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.STATE)}
+ )
+ await hass.async_block_till_done()
+
+ assert result["step_id"] == str(ObservationTypes.STATE)
+ assert result["type"] is FlowResultType.FORM
+
+ # Observations with a probability of 0 will create certainties
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 0,
+ CONF_P_GIVEN_F: 60,
+ CONF_NAME: "Work laptop on network",
+ },
+ )
+ assert CONF_P_GIVEN_T in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_prob_given_error"
+
+ # Observations with a probability of 1 will create certainties
+ with pytest.raises(vol.Invalid) as excinfo:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 60,
+ CONF_P_GIVEN_F: 100,
+ CONF_NAME: "Work laptop on network",
+ },
+ )
+ assert CONF_P_GIVEN_F in excinfo.value.path
+ assert excinfo.value.error_message == "extreme_prob_given_error"
+
+ # Observations with equal probabilities have no effect
+ # Try with a ObservationTypes.STATE observation
+ current_step = result["step_id"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 60,
+ CONF_P_GIVEN_F: 60,
+ CONF_NAME: "Work laptop on network",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["step_id"] == current_step
+ assert result["errors"] == {"base": "equal_probabilities"}
+
+ # now submit a valid result
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.work_laptop",
+ CONF_TO_STATE: "on",
+ CONF_P_GIVEN_T: 60,
+ CONF_P_GIVEN_F: 70,
+ CONF_NAME: "Work laptop on network",
+ },
+ )
+ await hass.async_block_till_done()
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.NUMERIC_STATE)}
+ )
+
+ await hass.async_block_till_done()
+ current_step = result["step_id"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 85,
+ CONF_P_GIVEN_F: 85,
+ CONF_NAME: "Office is bright",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["step_id"] == current_step
+ assert result["errors"] == {"base": "equal_probabilities"}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ENTITY_ID: "sensor.office_illuminance_lux",
+ CONF_ABOVE: 40,
+ CONF_P_GIVEN_T: 85,
+ CONF_P_GIVEN_F: 10,
+ CONF_NAME: "Office is bright",
+ },
+ )
+ await hass.async_block_till_done()
+ # Try with a ObservationTypes.TEMPLATE observation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"next_step_id": str(ObservationTypes.TEMPLATE)}
+ )
+
+ await hass.async_block_till_done()
+ current_step = result["step_id"]
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_VALUE_TEMPLATE: "{{ is_state('device_tracker.paulus', 'not_home') }}",
+ CONF_P_GIVEN_T: 50,
+ CONF_P_GIVEN_F: 50,
+ CONF_NAME: "Paulus not home",
+ },
+ )
+ await hass.async_block_till_done()
+ assert result["step_id"] == current_step
+ assert result["errors"] == {"base": "equal_probabilities"}
diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py
index 402d644747a..a5c4f7ff82a 100644
--- a/tests/components/blue_current/__init__.py
+++ b/tests/components/blue_current/__init__.py
@@ -10,7 +10,8 @@ from unittest.mock import MagicMock, patch
from bluecurrent_api import Client
from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE
-from homeassistant.components.blue_current.const import PUBLIC_CHARGING
+from homeassistant.components.blue_current.const import PUBLIC_CHARGING, UID
+from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -87,6 +88,16 @@ def create_client_mock(
"""Send the grid status to the callback."""
await client_mock.receiver({"object": "GRID_STATUS", "data": grid})
+ async def get_charge_cards() -> None:
+ """Send the charge cards list to the callback."""
+ await client_mock.receiver(
+ {
+ "object": "CHARGE_CARDS",
+ "default_card": {UID: "BCU-APP", CONF_ID: "BCU-APP"},
+ "cards": [{UID: "MOCK-CARD", CONF_ID: "MOCK-CARD", "valid": 1}],
+ }
+ )
+
async def update_charge_point(
evse_id: str, event_object: str, settings: dict[str, Any]
) -> None:
@@ -100,6 +111,7 @@ def create_client_mock(
client_mock.get_charge_points.side_effect = get_charge_points
client_mock.get_status.side_effect = get_status
client_mock.get_grid_status.side_effect = get_grid_status
+ client_mock.get_charge_cards.side_effect = get_charge_cards
client_mock.update_charge_point = update_charge_point
return client_mock
@@ -108,7 +120,7 @@ def create_client_mock(
async def init_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
- platform="",
+ platform: str | None = None,
charge_point: dict | None = None,
status: dict | None = None,
grid: dict | None = None,
@@ -124,6 +136,10 @@ async def init_integration(
if grid is None:
grid = {}
+ platforms = [platform] if platform else []
+ if platform:
+ platforms.append(platform)
+
future_container = FutureContainer(hass.loop.create_future())
started_loop = Event()
@@ -132,7 +148,7 @@ async def init_integration(
)
with (
- patch("homeassistant.components.blue_current.PLATFORMS", [platform]),
+ patch("homeassistant.components.blue_current.PLATFORMS", platforms),
patch("homeassistant.components.blue_current.Client", return_value=client_mock),
):
config_entry.add_to_hass(hass)
diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py
index b740e6c91f9..563a8392dc8 100644
--- a/tests/components/blue_current/test_init.py
+++ b/tests/components/blue_current/test_init.py
@@ -1,7 +1,7 @@
"""Test Blue Current Init Component."""
from datetime import timedelta
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
from bluecurrent_api.exceptions import (
BlueCurrentException,
@@ -10,15 +10,24 @@ from bluecurrent_api.exceptions import (
WebsocketError,
)
import pytest
+from voluptuous import MultipleInvalid
-from homeassistant.components.blue_current import async_setup_entry
+from homeassistant.components.blue_current import (
+ CHARGING_CARD_ID,
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ async_setup_entry,
+)
from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
IntegrationError,
+ ServiceValidationError,
)
+from homeassistant.helpers.device_registry import DeviceRegistry
from . import init_integration
@@ -32,6 +41,7 @@ async def test_load_unload_entry(
with (
patch("homeassistant.components.blue_current.Client.validate_api_token"),
patch("homeassistant.components.blue_current.Client.wait_for_charge_points"),
+ patch("homeassistant.components.blue_current.Client.get_charge_cards"),
patch("homeassistant.components.blue_current.Client.disconnect"),
patch(
"homeassistant.components.blue_current.Client.connect",
@@ -103,3 +113,108 @@ async def test_connect_request_limit_reached_error(
await started_loop.wait()
assert mock_client.get_next_reset_delta.call_count == 1
assert mock_client.connect.call_count == 2
+
+
+async def test_start_charging_action(
+ hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry
+) -> None:
+ """Test the start charing action when a charging card is provided."""
+ integration = await init_integration(hass, config_entry, Platform.BUTTON)
+ client = integration[0]
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {
+ CONF_DEVICE_ID: list(device_registry.devices)[0],
+ CHARGING_CARD_ID: "TEST_CARD",
+ },
+ blocking=True,
+ )
+
+ client.start_session.assert_called_once_with("101", "TEST_CARD")
+
+
+async def test_start_charging_action_without_card(
+ hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry
+) -> None:
+ """Test the start charing action when no charging card is provided."""
+ integration = await init_integration(hass, config_entry, Platform.BUTTON)
+ client = integration[0]
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {
+ CONF_DEVICE_ID: list(device_registry.devices)[0],
+ },
+ blocking=True,
+ )
+
+ client.start_session.assert_called_once_with("101", "BCU-APP")
+
+
+async def test_start_charging_action_errors(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ device_registry: DeviceRegistry,
+) -> None:
+ """Test the start charing action errors."""
+ await init_integration(hass, config_entry, Platform.BUTTON)
+
+ with pytest.raises(MultipleInvalid):
+ # No device id
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {},
+ blocking=True,
+ )
+
+ with pytest.raises(ServiceValidationError):
+ # Invalid device id
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {CONF_DEVICE_ID: "INVALID"},
+ blocking=True,
+ )
+
+ # Test when the device is not connected to a valid blue_current config entry.
+ get_entry_mock = MagicMock()
+ get_entry_mock.state = ConfigEntryState.LOADED
+
+ with (
+ patch.object(
+ hass.config_entries, "async_get_entry", return_value=get_entry_mock
+ ),
+ pytest.raises(ServiceValidationError),
+ ):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {
+ CONF_DEVICE_ID: list(device_registry.devices)[0],
+ },
+ blocking=True,
+ )
+
+ # Test when the blue_current config entry is not loaded.
+ get_entry_mock = MagicMock()
+ get_entry_mock.domain = DOMAIN
+ get_entry_mock.state = ConfigEntryState.NOT_LOADED
+
+ with (
+ patch.object(
+ hass.config_entries, "async_get_entry", return_value=get_entry_mock
+ ),
+ pytest.raises(ServiceValidationError),
+ ):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_START_CHARGE_SESSION,
+ {
+ CONF_DEVICE_ID: list(device_registry.devices)[0],
+ },
+ blocking=True,
+ )
diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py
index cccbaa3db3e..9a080a709f9 100644
--- a/tests/components/blueprint/test_importer.py
+++ b/tests/components/blueprint/test_importer.py
@@ -146,7 +146,9 @@ async def test_fetch_blueprint_from_github_url(
assert imported_blueprint.blueprint.domain == "automation"
assert imported_blueprint.blueprint.inputs == {
"service_to_call": None,
- "trigger_event": {"selector": {"text": {}}},
+ "trigger_event": {
+ "selector": {"text": {"multiline": False, "multiple": False}}
+ },
"a_number": {"selector": {"number": {"mode": "box", "step": 1.0}}},
}
assert imported_blueprint.suggested_filename == "balloob/motion_light"
diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py
index 921088d8ac6..8374054ca95 100644
--- a/tests/components/blueprint/test_websocket_api.py
+++ b/tests/components/blueprint/test_websocket_api.py
@@ -58,7 +58,9 @@ async def test_list_blueprints(
"domain": "automation",
"input": {
"service_to_call": None,
- "trigger_event": {"selector": {"text": {}}},
+ "trigger_event": {
+ "selector": {"text": {"multiline": False, "multiple": False}}
+ },
"a_number": {"selector": {"number": {"mode": "box", "step": 1.0}}},
},
"name": "Call service based on event",
@@ -69,7 +71,9 @@ async def test_list_blueprints(
"domain": "automation",
"input": {
"service_to_call": None,
- "trigger_event": {"selector": {"text": {}}},
+ "trigger_event": {
+ "selector": {"text": {"multiline": False, "multiple": False}}
+ },
"a_number": {"selector": {"number": {"mode": "box", "step": 1.0}}},
},
"name": "Call service based on event",
@@ -133,7 +137,9 @@ async def test_import_blueprint(
"domain": "automation",
"input": {
"service_to_call": None,
- "trigger_event": {"selector": {"text": {}}},
+ "trigger_event": {
+ "selector": {"text": {"multiline": False, "multiple": False}}
+ },
"a_number": {"selector": {"number": {"mode": "box", "step": 1.0}}},
},
"name": "Call service based on event",
@@ -219,21 +225,51 @@ async def test_save_blueprint(
output_yaml = write_mock.call_args[0][0]
assert output_yaml in (
# pure python dumper will quote the value after !input
- "blueprint:\n name: Call service based on event\n domain: automation\n "
- " input:\n trigger_event:\n selector:\n text: {}\n "
- " service_to_call:\n a_number:\n selector:\n number:\n "
- " mode: box\n step: 1.0\n source_url:"
- " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n"
- " trigger: event\n event_type: !input 'trigger_event'\nactions:\n "
- " service: !input 'service_to_call'\n entity_id: light.kitchen\n"
+ "blueprint:\n"
+ " name: Call service based on event\n"
+ " domain: automation\n"
+ " input:\n"
+ " trigger_event:\n"
+ " selector:\n"
+ " text:\n"
+ " multiline: false\n"
+ " multiple: false\n"
+ " service_to_call:\n"
+ " a_number:\n"
+ " selector:\n"
+ " number:\n"
+ " mode: box\n"
+ " step: 1.0\n"
+ " source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\n"
+ "triggers:\n"
+ " trigger: event\n"
+ " event_type: !input 'trigger_event'\n"
+ "actions:\n"
+ " service: !input 'service_to_call'\n"
+ " entity_id: light.kitchen\n",
# c dumper will not quote the value after !input
- "blueprint:\n name: Call service based on event\n domain: automation\n "
- " input:\n trigger_event:\n selector:\n text: {}\n "
- " service_to_call:\n a_number:\n selector:\n number:\n "
- " mode: box\n step: 1.0\n source_url:"
- " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n"
- " trigger: event\n event_type: !input trigger_event\nactions:\n service:"
- " !input service_to_call\n entity_id: light.kitchen\n"
+ "blueprint:\n"
+ " name: Call service based on event\n"
+ " domain: automation\n"
+ " input:\n"
+ " trigger_event:\n"
+ " selector:\n"
+ " text:\n"
+ " multiline: false\n"
+ " multiple: false\n"
+ " service_to_call:\n"
+ " a_number:\n"
+ " selector:\n"
+ " number:\n"
+ " mode: box\n"
+ " step: 1.0\n"
+ " source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\n"
+ "triggers:\n"
+ " trigger: event\n"
+ " event_type: !input trigger_event\n"
+ "actions:\n"
+ " service: !input service_to_call\n"
+ " entity_id: light.kitchen\n",
)
# Make sure ita parsable and does not raise
assert len(parse_yaml(output_yaml)) > 1
diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py
index 63597ed0532..4a793967645 100644
--- a/tests/components/bluesound/conftest.py
+++ b/tests/components/bluesound/conftest.py
@@ -98,6 +98,9 @@ class PlayerMockData:
return_value=[
Input("1", "input1", "image1", "url1"),
Input("2", "input2", "image2", "url2"),
+ Input(None, "input3", "image3", "url3"),
+ Input("4", None, "image4", "url4"),
+ Input(None, None, "image5", "url5"),
]
)
player.presets = AsyncMock(
diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr
index f71302f286d..24e04160e90 100644
--- a/tests/components/bluesound/snapshots/test_media_player.ambr
+++ b/tests/components/bluesound/snapshots/test_media_player.ambr
@@ -12,10 +12,12 @@
'media_title': 'song',
'shuffle': False,
'source_list': list([
- 'input1',
- 'input2',
'preset1',
'preset2',
+ 'input1',
+ 'input2',
+ 'input3',
+ '4',
]),
'supported_features': ,
'volume_level': 0.1,
diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py
index d2a72200423..b534c7aafb0 100644
--- a/tests/components/bluesound/test_media_player.py
+++ b/tests/components/bluesound/test_media_player.py
@@ -121,17 +121,30 @@ async def test_volume_down(
player_mocks.player_data.player.volume.assert_called_once_with(level=9)
+@pytest.mark.parametrize(
+ ("input", "url"),
+ [
+ ("input1", "url1"),
+ ("input2", "url2"),
+ ("input3", "url3"),
+ ("4", "url4"),
+ ],
+)
async def test_select_input_source(
- hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks
+ hass: HomeAssistant,
+ setup_config_entry: None,
+ player_mocks: PlayerMocks,
+ input: str,
+ url: str,
) -> None:
"""Test the media player select input source."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOURCE,
- {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "input1"},
+ {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: input},
)
- player_mocks.player_data.player.play_url.assert_called_once_with("url1")
+ player_mocks.player_data.player.play_url.assert_called_once_with(url)
async def test_select_preset_source(
diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py
index 74373da6865..2afd59e83cf 100644
--- a/tests/components/bluetooth/test_api.py
+++ b/tests/components/bluetooth/test_api.py
@@ -9,6 +9,7 @@ from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BaseHaRemoteScanner,
+ BluetoothScanningMode,
HaBluetoothConnector,
async_scanner_by_source,
async_scanner_devices_by_address,
@@ -16,6 +17,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.core import HomeAssistant
from . import (
+ FakeRemoteScanner,
FakeScanner,
MockBleakClient,
_get_manager,
@@ -161,3 +163,68 @@ async def test_async_scanner_devices_by_address_non_connectable(
assert devices[0].ble_device.name == switchbot_device.name
assert devices[0].advertisement.local_name == switchbot_device_adv.local_name
cancel()
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_async_current_scanners(hass: HomeAssistant) -> None:
+ """Test getting the list of current scanners."""
+ # The enable_bluetooth fixture registers one scanner
+ initial_scanners = bluetooth.async_current_scanners(hass)
+ assert len(initial_scanners) == 1
+ initial_scanner_count = len(initial_scanners)
+
+ # Verify current_mode is accessible on the initial scanner
+ for scanner in initial_scanners:
+ assert hasattr(scanner, "current_mode")
+ # The mode might be None or a BluetoothScanningMode enum value
+
+ # Register additional connectable scanners
+ hci0_scanner = FakeScanner("hci0", "hci0")
+ hci1_scanner = FakeScanner("hci1", "hci1")
+ cancel_hci0 = bluetooth.async_register_scanner(hass, hci0_scanner)
+ cancel_hci1 = bluetooth.async_register_scanner(hass, hci1_scanner)
+
+ # Test that the new scanners are added
+ scanners = bluetooth.async_current_scanners(hass)
+ assert len(scanners) == initial_scanner_count + 2
+ assert hci0_scanner in scanners
+ assert hci1_scanner in scanners
+
+ # Verify current_mode is accessible on all scanners
+ for scanner in scanners:
+ assert hasattr(scanner, "current_mode")
+ # Verify it's None or the correct type (BluetoothScanningMode)
+ assert scanner.current_mode is None or isinstance(
+ scanner.current_mode, BluetoothScanningMode
+ )
+
+ # Register non-connectable scanner
+ connector = HaBluetoothConnector(
+ MockBleakClient, "mock_bleak_client", lambda: False
+ )
+ hci2_scanner = FakeRemoteScanner("hci2", "hci2", connector, False)
+ cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner)
+
+ # Test that all scanners are returned (both connectable and non-connectable)
+ all_scanners = bluetooth.async_current_scanners(hass)
+ assert len(all_scanners) == initial_scanner_count + 3
+ assert hci0_scanner in all_scanners
+ assert hci1_scanner in all_scanners
+ assert hci2_scanner in all_scanners
+
+ # Verify current_mode is accessible on all scanners including non-connectable
+ for scanner in all_scanners:
+ assert hasattr(scanner, "current_mode")
+ # The mode should be None or a BluetoothScanningMode instance
+ assert scanner.current_mode is None or isinstance(
+ scanner.current_mode, BluetoothScanningMode
+ )
+
+ # Clean up our scanners
+ cancel_hci0()
+ cancel_hci1()
+ cancel_hci2()
+
+ # Verify we're back to the initial scanner
+ final_scanners = bluetooth.async_current_scanners(hass)
+ assert len(final_scanners) == initial_scanner_count
diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py
index 5c4d8bda70d..599d6833163 100644
--- a/tests/components/bluetooth/test_diagnostics.py
+++ b/tests/components/bluetooth/test_diagnostics.py
@@ -297,6 +297,7 @@ async def test_diagnostics_macos(
assert diag == {
"adapters": {
"Core Bluetooth": {
+ "adapter_type": None,
"address": "00:00:00:00:00:00",
"manufacturer": "Apple",
"passive_scan": False,
@@ -317,6 +318,7 @@ async def test_diagnostics_macos(
},
"adapters": {
"Core Bluetooth": {
+ "adapter_type": None,
"address": "00:00:00:00:00:00",
"manufacturer": "Apple",
"passive_scan": False,
diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py
index de299c58b93..d896cd83e76 100644
--- a/tests/components/bluetooth/test_init.py
+++ b/tests/components/bluetooth/test_init.py
@@ -4,7 +4,7 @@ import asyncio
from datetime import timedelta
import time
from typing import Any
-from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
from bleak import BleakError
from bleak.backends.scanner import AdvertisementData, BLEDevice
@@ -140,7 +140,6 @@ async def test_setup_and_stop_passive(
"adapter": "hci0",
"bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
"scanning_mode": "passive",
- "detection_callback": ANY,
}
@@ -190,7 +189,6 @@ async def test_setup_and_stop_old_bluez(
assert init_kwargs == {
"adapter": "hci0",
"scanning_mode": "active",
- "detection_callback": ANY,
}
diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py
index f34afba01ef..a9aa900e4a3 100644
--- a/tests/components/bluetooth/test_manager.py
+++ b/tests/components/bluetooth/test_manager.py
@@ -8,6 +8,7 @@ from unittest.mock import patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import AdvertisementHistory
from freezegun import freeze_time
+from habluetooth import BluetoothScanningMode, HaScanner
# pylint: disable-next=no-name-in-module
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
@@ -20,7 +21,6 @@ from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BaseHaRemoteScanner,
BluetoothChange,
- BluetoothScanningMode,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
@@ -38,6 +38,7 @@ from homeassistant.components.bluetooth.const import (
)
from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -47,6 +48,7 @@ from homeassistant.util.json import json_loads
from . import (
HCI0_SOURCE_ADDRESS,
HCI1_SOURCE_ADDRESS,
+ FakeRemoteScanner,
FakeScanner,
MockBleakClient,
_get_manager,
@@ -1737,3 +1739,304 @@ async def test_async_register_disappeared_callback(
cancel1()
cancel2()
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_repair_issue_created_for_degraded_scanner_in_docker(
+ hass: HomeAssistant,
+) -> None:
+ """Test repair issue is created when scanner is in degraded mode in Docker."""
+ await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ mock_adapters = {
+ "hci0": {
+ "address": "00:11:22:33:44:55",
+ "sw_version": "homeassistant",
+ "hw_version": "usb:v0A5Cp21E8",
+ "passive_scan": False,
+ "manufacturer": "Broadcom",
+ "product": "BCM20702A0",
+ "vendor_id": "0A5C",
+ "product_id": "21E8",
+ }
+ }
+
+ with (
+ patch("habluetooth.manager.IS_LINUX", True),
+ patch.object(type(manager), "is_operating_degraded", return_value=True),
+ patch(
+ "homeassistant.components.bluetooth.manager.is_docker_env",
+ return_value=True,
+ ),
+ patch.object(manager._bluetooth_adapters, "adapters", mock_adapters),
+ ):
+ manager.on_scanner_start(scanner)
+
+ issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"
+ registry = ir.async_get(hass)
+ issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
+ assert issue is not None
+ assert issue.severity == ir.IssueSeverity.WARNING
+ assert not issue.is_fixable
+ assert issue.translation_key == "bluetooth_adapter_missing_permissions"
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_repair_issue_deleted_when_scanner_not_degraded(
+ hass: HomeAssistant,
+) -> None:
+ """Test repair issue is deleted when scanner is not in degraded mode."""
+ await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+ registry = ir.async_get(hass)
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ mock_adapters = {
+ "hci0": {
+ "address": "00:11:22:33:44:55",
+ "sw_version": "homeassistant",
+ "hw_version": "usb:v0A5Cp21E8",
+ "passive_scan": False,
+ "manufacturer": "Broadcom",
+ "product": "BCM20702A0",
+ "vendor_id": "0A5C",
+ "product_id": "21E8",
+ }
+ }
+
+ issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"
+
+ with (
+ patch("habluetooth.manager.IS_LINUX", True),
+ patch.object(type(manager), "is_operating_degraded", return_value=True),
+ patch(
+ "homeassistant.components.bluetooth.manager.is_docker_env",
+ return_value=True,
+ ),
+ patch.object(manager._bluetooth_adapters, "adapters", mock_adapters),
+ ):
+ manager.on_scanner_start(scanner)
+
+ assert registry.async_get_issue(bluetooth.DOMAIN, issue_id) is not None
+
+ with (
+ patch(
+ "homeassistant.components.bluetooth.manager.is_docker_env",
+ return_value=True,
+ ),
+ patch.object(type(manager), "is_operating_degraded", return_value=False),
+ ):
+ manager.on_scanner_start(scanner)
+
+ assert registry.async_get_issue(bluetooth.DOMAIN, issue_id) is None
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_no_repair_issue_when_not_docker(
+ hass: HomeAssistant,
+) -> None:
+ """Test no repair issue is created when not running in Docker."""
+ assert await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ with (
+ patch(
+ "homeassistant.components.bluetooth.manager.is_docker_env",
+ return_value=False,
+ ),
+ patch.object(type(manager), "is_operating_degraded", return_value=True),
+ ):
+ manager.on_scanner_start(scanner)
+
+ issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(bluetooth.DOMAIN, issue_id) is None
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_no_repair_issue_for_remote_scanner(
+ hass: HomeAssistant,
+) -> None:
+ """Test no repair issue is created for remote scanners."""
+ assert await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ connector = HaBluetoothConnector(MockBleakClient, "mock_connector", lambda: False)
+ scanner = FakeRemoteScanner("remote_scanner", "esp32", connector, True)
+
+ with (
+ patch(
+ "homeassistant.components.bluetooth.manager.is_docker_env",
+ return_value=True,
+ ),
+ patch.object(type(manager), "is_operating_degraded", return_value=True),
+ ):
+ manager.on_scanner_start(scanner)
+
+ registry = ir.async_get(hass)
+ issues = [
+ issue
+ for issue in registry.issues.values()
+ if issue.domain == bluetooth.DOMAIN
+ and "bluetooth_adapter_missing_permissions" in issue.issue_id
+ ]
+ assert len(issues) == 0
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_repair_issue_created_for_passive_mode_fallback(
+ hass: HomeAssistant,
+) -> None:
+ """Test repair issue is created when scanner falls back to passive mode."""
+ assert await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ cancel = manager.async_register_scanner(scanner, connection_slots=1)
+
+ # Set scanner to passive mode when active was requested
+ scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
+ scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
+
+ manager.on_scanner_start(scanner)
+
+ # Check repair issue is created
+ issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
+ registry = ir.async_get(hass)
+ issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
+ assert issue is not None
+ assert issue.severity == ir.IssueSeverity.WARNING
+ # Should default to USB translation key when adapter type is unknown
+ assert issue.translation_key == "bluetooth_adapter_passive_mode_usb"
+ assert not issue.is_fixable
+
+ cancel()
+
+
+async def test_repair_issue_created_for_passive_mode_fallback_uart(
+ hass: HomeAssistant,
+) -> None:
+ """Test repair issue is created with UART-specific message for UART adapters."""
+ with patch(
+ "bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
+ {
+ "hci0": {
+ "address": "00:11:22:33:44:55",
+ "sw_version": "homeassistant",
+ "hw_version": "uart:bcm2711",
+ "passive_scan": False,
+ "manufacturer": "Raspberry Pi",
+ "product": "BCM2711",
+ "adapter_type": "uart", # UART adapter type
+ }
+ },
+ ):
+ assert await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ cancel = manager.async_register_scanner(scanner, connection_slots=1)
+
+ # Set scanner to passive mode when active was requested
+ scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
+ scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
+
+ manager.on_scanner_start(scanner)
+
+ # Check repair issue is created with UART-specific translation key
+ issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
+ registry = ir.async_get(hass)
+ issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
+ assert issue is not None
+ assert issue.severity == ir.IssueSeverity.WARNING
+ assert issue.translation_key == "bluetooth_adapter_passive_mode_uart"
+ assert not issue.is_fixable
+
+ cancel()
+
+
+@pytest.mark.usefixtures("one_adapter")
+async def test_repair_issue_deleted_when_passive_mode_resolved(
+ hass: HomeAssistant,
+) -> None:
+ """Test repair issue is deleted when scanner no longer in passive mode."""
+ assert await async_setup_component(hass, bluetooth.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ manager = _get_manager()
+
+ scanner = HaScanner(
+ mode=BluetoothScanningMode.ACTIVE,
+ adapter="hci0",
+ address="00:11:22:33:44:55",
+ )
+ scanner.async_setup()
+
+ cancel = manager.async_register_scanner(scanner, connection_slots=1)
+
+ # Initially set scanner to passive mode when active was requested
+ scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
+ scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
+
+ manager.on_scanner_start(scanner)
+
+ # Check repair issue is created
+ issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
+ registry = ir.async_get(hass)
+ issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
+ assert issue is not None
+
+ # Now simulate scanner recovering to active mode
+ scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
+ manager.on_scanner_start(scanner)
+
+ # Check repair issue is deleted
+ issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
+ assert issue is None
+
+ cancel()
diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py
index f12d77913a9..1bb76065a5d 100644
--- a/tests/components/bluetooth/test_websocket_api.py
+++ b/tests/components/bluetooth/test_websocket_api.py
@@ -7,6 +7,7 @@ from unittest.mock import ANY, patch
from bleak_retry_connector import Allocations
from freezegun import freeze_time
+from habluetooth import BluetoothScanningMode
import pytest
from homeassistant.components.bluetooth import DOMAIN
@@ -332,6 +333,7 @@ async def test_subscribe_scanner_details(
"connectable": False,
"name": "hci0 (00:00:00:00:00:01)",
"source": "00:00:00:00:00:01",
+ "scanner_type": "unknown",
}
]
}
@@ -349,6 +351,7 @@ async def test_subscribe_scanner_details(
"connectable": False,
"name": "hci3 (AA:BB:CC:DD:EE:33)",
"source": "AA:BB:CC:DD:EE:33",
+ "scanner_type": "unknown",
}
]
}
@@ -362,6 +365,7 @@ async def test_subscribe_scanner_details(
"connectable": False,
"name": "hci3 (AA:BB:CC:DD:EE:33)",
"source": "AA:BB:CC:DD:EE:33",
+ "scanner_type": "unknown",
}
]
}
@@ -399,6 +403,7 @@ async def test_subscribe_scanner_details_specific_scanner(
"connectable": False,
"name": "hci3 (AA:BB:CC:DD:EE:33)",
"source": "AA:BB:CC:DD:EE:33",
+ "scanner_type": "unknown",
}
]
}
@@ -412,6 +417,7 @@ async def test_subscribe_scanner_details_specific_scanner(
"connectable": False,
"name": "hci3 (AA:BB:CC:DD:EE:33)",
"source": "AA:BB:CC:DD:EE:33",
+ "scanner_type": "unknown",
}
]
}
@@ -435,4 +441,126 @@ async def test_subscribe_scanner_details_invalid_config_entry_id(
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_config_entry_id"
- assert response["error"]["message"] == "Invalid config entry id: non_existent"
+ assert response["error"]["message"] == "Config entry non_existent not found"
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_state(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_scanner_state."""
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_state",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ # Should receive initial state for existing scanner
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "source": "00:00:00:00:00:01",
+ "adapter": "hci0",
+ "current_mode": "active",
+ "requested_mode": "active",
+ }
+
+ # Register a new scanner
+ manager = _get_manager()
+ hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
+ cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner)
+
+ # Simulate a mode change
+ hci3_scanner.current_mode = BluetoothScanningMode.ACTIVE
+ hci3_scanner.requested_mode = BluetoothScanningMode.ACTIVE
+ manager.scanner_mode_changed(hci3_scanner)
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "source": "AA:BB:CC:DD:EE:33",
+ "adapter": "hci3",
+ "current_mode": "active",
+ "requested_mode": "active",
+ }
+
+ cancel_hci3()
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_state_specific_scanner(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_scanner_state for a specific source address."""
+ # Register the scanner first
+ manager = _get_manager()
+ hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
+ cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner)
+
+ entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33")
+ entry.add_to_hass(hass)
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_state",
+ "config_entry_id": entry.entry_id,
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["success"]
+
+ # Should receive initial state
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "source": "AA:BB:CC:DD:EE:33",
+ "adapter": "hci3",
+ "current_mode": None,
+ "requested_mode": None,
+ }
+
+ # Simulate a mode change
+ hci3_scanner.current_mode = BluetoothScanningMode.PASSIVE
+ hci3_scanner.requested_mode = BluetoothScanningMode.ACTIVE
+ manager.scanner_mode_changed(hci3_scanner)
+
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert response["event"] == {
+ "source": "AA:BB:CC:DD:EE:33",
+ "adapter": "hci3",
+ "current_mode": "passive",
+ "requested_mode": "active",
+ }
+
+ cancel_hci3()
+
+
+@pytest.mark.usefixtures("enable_bluetooth")
+async def test_subscribe_scanner_state_invalid_config_entry_id(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test bluetooth subscribe_scanner_state for an invalid config entry id."""
+ client = await hass_ws_client()
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "bluetooth/subscribe_scanner_state",
+ "config_entry_id": "non_existent",
+ }
+ )
+ async with asyncio.timeout(1):
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "invalid_config_entry_id"
+ assert response["error"]["message"] == "Config entry non_existent not found"
diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
index b87da22a332..06e90c878af 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
@@ -138,6 +138,7 @@
'state': 'LOW',
}),
]),
+ 'urgent_check_control_messages': None,
}),
'climate': dict({
'activity': 'INACTIVE',
@@ -193,6 +194,24 @@
'state': 'OK',
}),
]),
+ 'next_service_by_distance': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
+ 'next_service_by_time': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
}),
'data': dict({
'attributes': dict({
@@ -1053,6 +1072,7 @@
'state': 'LOW',
}),
]),
+ 'urgent_check_control_messages': None,
}),
'climate': dict({
'activity': 'HEATING',
@@ -1108,6 +1128,24 @@
'state': 'OK',
}),
]),
+ 'next_service_by_distance': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
+ 'next_service_by_time': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
}),
'data': dict({
'attributes': dict({
@@ -1858,6 +1896,7 @@
'state': 'LOW',
}),
]),
+ 'urgent_check_control_messages': None,
}),
'climate': dict({
'activity': 'INACTIVE',
@@ -1922,6 +1961,24 @@
'state': 'OK',
}),
]),
+ 'next_service_by_distance': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
+ 'next_service_by_time': dict({
+ 'due_date': '2024-12-01T00:00:00+00:00',
+ 'due_distance': list([
+ 50000,
+ 'km',
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
}),
'data': dict({
'attributes': dict({
@@ -2621,6 +2678,7 @@
'has_check_control_messages': False,
'messages': list([
]),
+ 'urgent_check_control_messages': None,
}),
'climate': dict({
'activity': 'UNKNOWN',
@@ -2658,6 +2716,16 @@
'state': 'OK',
}),
]),
+ 'next_service_by_distance': None,
+ 'next_service_by_time': dict({
+ 'due_date': '2022-10-01T00:00:00+00:00',
+ 'due_distance': list([
+ None,
+ None,
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
}),
'data': dict({
'attributes': dict({
@@ -4991,6 +5059,7 @@
'has_check_control_messages': False,
'messages': list([
]),
+ 'urgent_check_control_messages': None,
}),
'climate': dict({
'activity': 'UNKNOWN',
@@ -5028,6 +5097,16 @@
'state': 'OK',
}),
]),
+ 'next_service_by_distance': None,
+ 'next_service_by_time': dict({
+ 'due_date': '2022-10-01T00:00:00+00:00',
+ 'due_distance': list([
+ None,
+ None,
+ ]),
+ 'service_type': 'BRAKE_FLUID',
+ 'state': 'OK',
+ }),
}),
'data': dict({
'attributes': dict({
diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr
index e6bc20a2216..c46a998913c 100644
--- a/tests/components/braviatv/snapshots/test_diagnostics.ambr
+++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr
@@ -37,5 +37,7 @@
'region': 'XEU',
'serial': 'serial_number',
}),
+ 'remote_command_list': list([
+ ]),
})
# ---
diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py
index ecaa82678e6..8ba5d79c886 100644
--- a/tests/components/braviatv/test_diagnostics.py
+++ b/tests/components/braviatv/test_diagnostics.py
@@ -69,6 +69,7 @@ async def test_entry_diagnostics(
patch("pybravia.BraviaClient.get_playing_info", return_value={}),
patch("pybravia.BraviaClient.get_app_list", return_value=[]),
patch("pybravia.BraviaClient.get_content_list_all", return_value=[]),
+ patch("pybravia.BraviaClient.get_command_list", return_value=[]),
):
assert await async_setup_component(hass, DOMAIN, {})
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py
index de22158da00..82a8d52a76e 100644
--- a/tests/components/brother/conftest.py
+++ b/tests/components/brother/conftest.py
@@ -7,8 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
from brother import BrotherSensors
import pytest
-from homeassistant.components.brother.const import DOMAIN
-from homeassistant.const import CONF_HOST, CONF_TYPE
+from homeassistant.components.brother.const import (
+ CONF_COMMUNITY,
+ DOMAIN,
+ SECTION_ADVANCED_SETTINGS,
+)
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from tests.common import MockConfigEntry
@@ -122,5 +126,10 @@ def mock_config_entry() -> MockConfigEntry:
domain=DOMAIN,
title="HL-L2340DW 0123456789",
unique_id="0123456789",
- data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
+ data={
+ CONF_HOST: "localhost",
+ CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
+ minor_version=2,
)
diff --git a/tests/components/brother/snapshots/test_diagnostics.ambr b/tests/components/brother/snapshots/test_diagnostics.ambr
index 614588bf829..2bd9adffbe1 100644
--- a/tests/components/brother/snapshots/test_diagnostics.ambr
+++ b/tests/components/brother/snapshots/test_diagnostics.ambr
@@ -66,6 +66,10 @@
}),
'firmware': '1.2.3',
'info': dict({
+ 'advanced_settings': dict({
+ 'community': 'public',
+ 'port': 161,
+ }),
'host': 'localhost',
'type': 'laser',
}),
diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py
index 945f5549bbe..dfec3077832 100644
--- a/tests/components/brother/test_config_flow.py
+++ b/tests/components/brother/test_config_flow.py
@@ -6,9 +6,13 @@ from unittest.mock import AsyncMock, patch
from brother import SnmpError, UnsupportedModelError
import pytest
-from homeassistant.components.brother.const import DOMAIN
+from homeassistant.components.brother.const import (
+ CONF_COMMUNITY,
+ DOMAIN,
+ SECTION_ADVANCED_SETTINGS,
+)
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
-from homeassistant.const import CONF_HOST, CONF_TYPE
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -17,7 +21,11 @@ from . import init_integration
from tests.common import MockConfigEntry
-CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"}
+CONFIG = {
+ CONF_HOST: "127.0.0.1",
+ CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+}
pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry")
@@ -37,16 +45,21 @@ async def test_create_entry(
hass: HomeAssistant, host: str, mock_brother_client: AsyncMock
) -> None:
"""Test that the user step works with printer hostname/IPv4/IPv6."""
+ config = CONFIG.copy()
+ config[CONF_HOST] = host
+
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
- data={CONF_HOST: host, CONF_TYPE: "laser"},
+ data=config,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == host
assert result["data"][CONF_TYPE] == "laser"
+ assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
+ assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
async def test_invalid_hostname(hass: HomeAssistant) -> None:
@@ -54,7 +67,11 @@ async def test_invalid_hostname(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
- data={CONF_HOST: "invalid/hostname", CONF_TYPE: "laser"},
+ data={
+ CONF_HOST: "invalid/hostname",
+ CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["errors"] == {CONF_HOST: "wrong_host"}
@@ -241,13 +258,19 @@ async def test_zeroconf_confirm_create_entry(
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_TYPE: "laser"}
+ result["flow_id"],
+ user_input={
+ CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == "127.0.0.1"
assert result["data"][CONF_TYPE] == "laser"
+ assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
+ assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
async def test_reconfigure_successful(
@@ -265,7 +288,10 @@ async def test_reconfigure_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={CONF_HOST: "10.10.10.10"},
+ user_input={
+ CONF_HOST: "10.10.10.10",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.ABORT
@@ -273,6 +299,7 @@ async def test_reconfigure_successful(
assert mock_config_entry.data == {
CONF_HOST: "10.10.10.10",
CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
@@ -303,7 +330,10 @@ async def test_reconfigure_not_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={CONF_HOST: "10.10.10.10"},
+ user_input={
+ CONF_HOST: "10.10.10.10",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.FORM
@@ -314,7 +344,10 @@ async def test_reconfigure_not_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={CONF_HOST: "10.10.10.10"},
+ user_input={
+ CONF_HOST: "10.10.10.10",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.ABORT
@@ -322,6 +355,7 @@ async def test_reconfigure_not_successful(
assert mock_config_entry.data == {
CONF_HOST: "10.10.10.10",
CONF_TYPE: "laser",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
@@ -340,7 +374,10 @@ async def test_reconfigure_invalid_hostname(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={CONF_HOST: "invalid/hostname"},
+ user_input={
+ CONF_HOST: "invalid/hostname",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.FORM
@@ -365,7 +402,10 @@ async def test_reconfigure_not_the_same_device(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- user_input={CONF_HOST: "10.10.10.10"},
+ user_input={
+ CONF_HOST: "10.10.10.10",
+ SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
+ },
)
assert result["type"] is FlowResultType.FORM
diff --git a/tests/components/brother/test_init.py b/tests/components/brother/test_init.py
index 1a2c6bf23f2..45702d91f20 100644
--- a/tests/components/brother/test_init.py
+++ b/tests/components/brother/test_init.py
@@ -5,8 +5,13 @@ from unittest.mock import AsyncMock, patch
from brother import SnmpError
import pytest
-from homeassistant.components.brother.const import DOMAIN
+from homeassistant.components.brother.const import (
+ CONF_COMMUNITY,
+ DOMAIN,
+ SECTION_ADVANCED_SETTINGS,
+)
from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from . import init_integration
@@ -68,3 +73,26 @@ async def test_unload_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
+
+
+async def test_migrate_entry(
+ hass: HomeAssistant,
+ mock_brother_client: AsyncMock,
+) -> None:
+ """Test entry migration to minor_version=2."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="HL-L2340DW 0123456789",
+ unique_id="0123456789",
+ data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
+ minor_version=1,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.minor_version == 2
+ assert config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
+ assert config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
diff --git a/tests/components/bsblan/snapshots/test_sensor.ambr b/tests/components/bsblan/snapshots/test_sensor.ambr
index eb80858eb5d..dc775330e60 100644
--- a/tests/components/bsblan/snapshots/test_sensor.ambr
+++ b/tests/components/bsblan/snapshots/test_sensor.ambr
@@ -29,7 +29,7 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Current Temperature',
+ 'original_name': 'Current temperature',
'platform': 'bsblan',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -43,7 +43,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
- 'friendly_name': 'BSB-LAN Current Temperature',
+ 'friendly_name': 'BSB-LAN Current temperature',
'state_class': ,
'unit_of_measurement': ,
}),
@@ -85,7 +85,7 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Outside Temperature',
+ 'original_name': 'Outside temperature',
'platform': 'bsblan',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -99,7 +99,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
- 'friendly_name': 'BSB-LAN Outside Temperature',
+ 'friendly_name': 'BSB-LAN Outside temperature',
'state_class': ,
'unit_of_measurement': ,
}),
diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py
index 41d566fc375..f35f0c7bdf3 100644
--- a/tests/components/bsblan/test_climate.py
+++ b/tests/components/bsblan/test_climate.py
@@ -91,6 +91,50 @@ async def test_climate_entity_properties(
assert state.attributes["preset_mode"] == PRESET_ECO
+async def test_climate_without_current_temperature_sensor(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test climate entity when current temperature sensor is not available."""
+ await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
+
+ # Set current_temperature to None to simulate no temperature sensor
+ mock_bsblan.state.return_value.current_temperature = None
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # Should not crash and current_temperature should be None in attributes
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes["current_temperature"] is None
+
+
+async def test_climate_without_target_temperature_sensor(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test climate entity when target temperature sensor is not available."""
+ await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
+
+ # Set target_temperature to None to simulate no temperature sensor
+ mock_bsblan.state.return_value.target_temperature = None
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # Should not crash and target temperature should be None in attributes
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes["temperature"] is None
+
+
@pytest.mark.parametrize(
"mode",
[HVACMode.HEAT, HVACMode.AUTO, HVACMode.OFF],
diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py
index ba2af40f319..fdfe8fec06b 100644
--- a/tests/components/bsblan/test_sensor.py
+++ b/tests/components/bsblan/test_sensor.py
@@ -28,3 +28,45 @@ async def test_sensor_entity_properties(
"""Test the sensor entity properties."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_sensors_not_created_when_data_unavailable(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test sensors are not created when sensor data is not available."""
+ # Set all sensor data to None to simulate no sensors available
+ mock_bsblan.sensor.return_value.current_temperature = None
+ mock_bsblan.sensor.return_value.outside_temperature = None
+
+ await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
+
+ # Should not create any sensor entities
+ entity_entries = er.async_entries_for_config_entry(
+ entity_registry, mock_config_entry.entry_id
+ )
+ sensor_entities = [entry for entry in entity_entries if entry.domain == "sensor"]
+ assert len(sensor_entities) == 0
+
+
+async def test_partial_sensors_created_when_some_data_available(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test only available sensors are created when some sensor data is available."""
+ # Only current temperature available, outside temperature not
+ mock_bsblan.sensor.return_value.outside_temperature = None
+
+ await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
+
+ # Should create only the current temperature sensor
+ entity_entries = er.async_entries_for_config_entry(
+ entity_registry, mock_config_entry.entry_id
+ )
+ sensor_entities = [entry for entry in entity_entries if entry.domain == "sensor"]
+ assert len(sensor_entities) == 1
+ assert sensor_entities[0].entity_id == ENTITY_CURRENT_TEMP
diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py
index 173498b14ff..466da1e6fda 100644
--- a/tests/components/bsblan/test_water_heater.py
+++ b/tests/components/bsblan/test_water_heater.py
@@ -50,6 +50,33 @@ async def test_water_heater_states(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+async def test_water_heater_no_dhw_capability(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test that no water heater entity is created when DHW capability is missing."""
+ # Mock DHW data to simulate no water heater capability
+ mock_bsblan.hot_water_state.return_value.operating_mode = None
+ mock_bsblan.hot_water_state.return_value.nominal_setpoint = None
+ mock_bsblan.hot_water_state.return_value.dhw_actual_value_top_temperature = None
+
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ # Verify no water heater entity was created
+ entities = er.async_entries_for_config_entry(
+ entity_registry, mock_config_entry.entry_id
+ )
+ water_heater_entities = [
+ entity for entity in entities if entity.domain == Platform.WATER_HEATER
+ ]
+
+ assert len(water_heater_entities) == 0
+
+
async def test_water_heater_entity_properties(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
@@ -208,3 +235,31 @@ async def test_operation_mode_error(
},
blocking=True,
)
+
+
+async def test_water_heater_no_sensors(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test water heater when sensors are not available."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ # Set all sensors to None to simulate missing sensors
+ mock_bsblan.hot_water_state.return_value.operating_mode = None
+ mock_bsblan.hot_water_state.return_value.dhw_actual_value_top_temperature = None
+ mock_bsblan.hot_water_state.return_value.nominal_setpoint = None
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # Should not crash and properties should return None
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+ assert state.attributes.get("current_operation") is None
+ assert state.attributes.get("current_temperature") is None
+ assert state.attributes.get("temperature") is None
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index 09aae385a89..37627b2f63f 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -3,7 +3,6 @@
from collections.abc import Callable
from http import HTTPStatus
import io
-from types import ModuleType
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch
import pytest
@@ -40,11 +39,7 @@ from homeassistant.util import dt as dt_util
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg
-from tests.common import (
- async_fire_time_changed,
- help_test_all,
- import_and_test_deprecated_constant_enum,
-)
+from tests.common import async_fire_time_changed
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -807,32 +802,6 @@ async def test_use_stream_for_stills(
assert await resp.read() == b"stream_keyframe_image"
-@pytest.mark.parametrize(
- "module",
- [camera],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(
- "enum",
- list(camera.const.CameraState),
-)
-@pytest.mark.parametrize(
- "module",
- [camera],
-)
-def test_deprecated_state_constants(
- caplog: pytest.LogCaptureFixture,
- enum: camera.const.StreamType,
- module: ModuleType,
-) -> None:
- """Test deprecated stream type constants."""
- import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10")
-
-
@pytest.mark.usefixtures("mock_camera")
async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None:
"""Test the token is rotated and entity entity picture cache is cleared."""
diff --git a/tests/components/camera/test_prefs.py b/tests/components/camera/test_prefs.py
new file mode 100644
index 00000000000..e4b3e67f15d
--- /dev/null
+++ b/tests/components/camera/test_prefs.py
@@ -0,0 +1,76 @@
+"""Test camera helper functions."""
+
+import pytest
+
+from homeassistant.components.camera.const import DATA_CAMERA_PREFS
+from homeassistant.components.camera.prefs import (
+ CameraPreferences,
+ DynamicStreamSettings,
+ get_dynamic_camera_stream_settings,
+)
+from homeassistant.components.stream import Orientation
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+
+async def test_get_dynamic_camera_stream_settings_missing_prefs(
+ hass: HomeAssistant,
+) -> None:
+ """Test get_dynamic_camera_stream_settings when camera prefs are not set up."""
+ with pytest.raises(HomeAssistantError, match="Camera integration not set up"):
+ await get_dynamic_camera_stream_settings(hass, "camera.test")
+
+
+async def test_get_dynamic_camera_stream_settings_success(hass: HomeAssistant) -> None:
+ """Test successful retrieval of dynamic camera stream settings."""
+ # Set up camera preferences
+ prefs = CameraPreferences(hass)
+ await prefs.async_load()
+ hass.data[DATA_CAMERA_PREFS] = prefs
+
+ # Test with default settings
+ settings = await get_dynamic_camera_stream_settings(hass, "camera.test")
+ assert settings.orientation == Orientation.NO_TRANSFORM
+ assert settings.preload_stream is False
+
+
+async def test_get_dynamic_camera_stream_settings_with_custom_orientation(
+ hass: HomeAssistant,
+) -> None:
+ """Test get_dynamic_camera_stream_settings with custom orientation set."""
+ # Set up camera preferences
+ prefs = CameraPreferences(hass)
+ await prefs.async_load()
+ hass.data[DATA_CAMERA_PREFS] = prefs
+
+ # Set custom orientation - this requires entity registry
+ # For this test, we'll directly manipulate the internal state
+ # since entity registry setup is complex for a unit test
+ test_settings = DynamicStreamSettings(
+ orientation=Orientation.ROTATE_LEFT, preload_stream=False
+ )
+ prefs._dynamic_stream_settings_by_entity_id["camera.test"] = test_settings
+
+ settings = await get_dynamic_camera_stream_settings(hass, "camera.test")
+ assert settings.orientation == Orientation.ROTATE_LEFT
+ assert settings.preload_stream is False
+
+
+async def test_get_dynamic_camera_stream_settings_with_preload_stream(
+ hass: HomeAssistant,
+) -> None:
+ """Test get_dynamic_camera_stream_settings with preload stream enabled."""
+ # Set up camera preferences
+ prefs = CameraPreferences(hass)
+ await prefs.async_load()
+ hass.data[DATA_CAMERA_PREFS] = prefs
+
+ # Set preload stream by directly setting the dynamic stream settings
+ test_settings = DynamicStreamSettings(
+ orientation=Orientation.NO_TRANSFORM, preload_stream=True
+ )
+ prefs._dynamic_stream_settings_by_entity_id["camera.test"] = test_settings
+
+ settings = await get_dynamic_camera_stream_settings(hass, "camera.test")
+ assert settings.orientation == Orientation.NO_TRANSFORM
+ assert settings.preload_stream is True
diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py
index 06bd9c0c096..fd53b29e140 100644
--- a/tests/components/climate/test_init.py
+++ b/tests/components/climate/test_init.py
@@ -232,7 +232,7 @@ async def test_temperature_features_is_valid(
with pytest.raises(
ServiceValidationError,
- match="Set temperature action was used with the target temperature parameter but the entity does not support it",
+ match="Set temperature action was used with the 'Target temperature' parameter but the entity does not support it",
):
await hass.services.async_call(
DOMAIN,
@@ -246,7 +246,7 @@ async def test_temperature_features_is_valid(
with pytest.raises(
ServiceValidationError,
- match="Set temperature action was used with the target temperature low/high parameter but the entity does not support it",
+ match="Set temperature action was used with the 'Lower/Upper target temperature' parameter but the entity does not support it",
):
await hass.services.async_call(
DOMAIN,
@@ -702,7 +702,7 @@ async def test_target_temp_high_higher_than_low(
with pytest.raises(
ServiceValidationError,
- match="Target temperature low can not be higher than Target temperature high",
+ match="'Lower target temperature' can not be higher than 'Upper target temperature'",
) as exc:
await hass.services.async_call(
DOMAIN,
@@ -716,6 +716,6 @@ async def test_target_temp_high_higher_than_low(
)
assert (
str(exc.value)
- == "Target temperature low can not be higher than Target temperature high"
+ == "'Lower target temperature' can not be higher than 'Upper target temperature'"
)
assert exc.value.translation_key == "low_temp_higher_than_high_temp"
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
index 52c544dc541..9e1f68e23f8 100644
--- a/tests/components/cloud/snapshots/test_http_api.ambr
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -19,6 +19,41 @@
timezone | US/Pacific
config_dir | config
+ ## Active Integrations
+
+ Built-in integrations: 15
+ Custom integrations: 1
+
+ Built-in integrations
+
+ Domain | Name
+ --- | ---
+ auth | Auth
+ binary_sensor | Binary Sensor
+ cloud | Home Assistant Cloud
+ cloud.binary_sensor | Unknown
+ cloud.stt | Unknown
+ cloud.tts | Unknown
+ ffmpeg | FFmpeg
+ homeassistant | Home Assistant Core Integration
+ http | HTTP
+ mock_no_info_integration | mock_no_info_integration
+ repairs | Repairs
+ stt | Speech-to-text (STT)
+ system_health | System Health
+ tts | Text-to-speech (TTS)
+ webhook | Webhook
+
+
+
+ Custom integrations
+
+ Domain | Name | Version | Documentation
+ --- | --- | --- | ---
+ test | Test Components | 1.2.3 | http://example.com
+
+
+
mock_no_info_integration
No information available
@@ -59,3 +94,156 @@
'''
# ---
+# name: test_download_support_package_custom_components_error
+ '''
+ ## System Information
+
+ version | core-2025.2.0
+ --- | ---
+ installation_type | Home Assistant Core
+ dev | False
+ hassio | False
+ docker | False
+ container_arch | None
+ user | hass
+ virtualenv | False
+ python_version | 3.13.1
+ os_name | Linux
+ os_version | 6.12.9
+ arch | x86_64
+ timezone | US/Pacific
+ config_dir | config
+
+ ## Active Integrations
+
+ Built-in integrations: 15
+ Custom integrations: 0
+
+ Built-in integrations
+
+ Domain | Name
+ --- | ---
+ auth | Auth
+ binary_sensor | Binary Sensor
+ cloud | Home Assistant Cloud
+ cloud.binary_sensor | Unknown
+ cloud.stt | Unknown
+ cloud.tts | Unknown
+ ffmpeg | FFmpeg
+ homeassistant | Home Assistant Core Integration
+ http | HTTP
+ mock_no_info_integration | mock_no_info_integration
+ repairs | Repairs
+ stt | Speech-to-text (STT)
+ system_health | System Health
+ tts | Text-to-speech (TTS)
+ webhook | Webhook
+
+
+
+ mock_no_info_integration
+
+ No information available
+
+
+ cloud
+
+ logged_in | True
+ --- | ---
+ subscription_expiration | 2025-01-17T11:19:31+00:00
+ relayer_connected | True
+ relayer_region | xx-earth-616
+ remote_enabled | True
+ remote_connected | False
+ alexa_enabled | True
+ google_enabled | False
+ cloud_ice_servers_enabled | True
+ remote_server | us-west-1
+ certificate_status | ready
+ instance_id | 12345678901234567890
+ can_reach_cert_server | Exception: Unexpected exception
+ can_reach_cloud_auth | Failed: unreachable
+ can_reach_cloud | ok
+
+
+
+ ## Full logs
+
+ Logs
+
+ ```logs
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
+ 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
+ 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
+ ```
+
+
+
+ '''
+# ---
+# name: test_download_support_package_integration_load_error
+ '''
+ ## System Information
+
+ version | core-2025.2.0
+ --- | ---
+ installation_type | Home Assistant Core
+ dev | False
+ hassio | False
+ docker | False
+ container_arch | None
+ user | hass
+ virtualenv | False
+ python_version | 3.13.1
+ os_name | Linux
+ os_version | 6.12.9
+ arch | x86_64
+ timezone | US/Pacific
+ config_dir | config
+
+ ## Active integrations
+
+ Unable to collect integration information
+
+ mock_no_info_integration
+
+ No information available
+
+
+ cloud
+
+ logged_in | True
+ --- | ---
+ subscription_expiration | 2025-01-17T11:19:31+00:00
+ relayer_connected | True
+ relayer_region | xx-earth-616
+ remote_enabled | True
+ remote_connected | False
+ alexa_enabled | True
+ google_enabled | False
+ cloud_ice_servers_enabled | True
+ remote_server | us-west-1
+ certificate_status | ready
+ instance_id | 12345678901234567890
+ can_reach_cert_server | Exception: Unexpected exception
+ can_reach_cloud_auth | Failed: unreachable
+ can_reach_cloud | ok
+
+
+
+ ## Full logs
+
+ Logs
+
+ ```logs
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
+ 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
+ 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
+ ```
+
+
+
+ '''
+# ---
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index 96927477b0a..5256ff8a509 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -36,6 +36,7 @@ from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
+from homeassistant.loader import async_get_loaded_integration
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
@@ -1840,6 +1841,7 @@ async def test_logout_view_dispatch_event(
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
+@pytest.mark.usefixtures("enable_custom_integrations")
async def test_download_support_package(
hass: HomeAssistant,
cloud: MagicMock,
@@ -1875,6 +1877,9 @@ async def test_download_support_package(
)
hass.config.components.add("mock_no_info_integration")
+ # Add mock custom integration for testing
+ hass.config.components.add("test") # This is a custom integration from the fixture
+
assert await async_setup_component(hass, "system_health", {})
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
@@ -1947,3 +1952,232 @@ async def test_download_support_package(
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
assert await req.text() == snapshot
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_download_support_package_custom_components_error(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test download support package when async_get_custom_components fails."""
+
+ aioclient_mock.get("https://cloud.bla.com/status", text="")
+ aioclient_mock.get(
+ "https://cert-server/directory", exc=Exception("Unexpected exception")
+ )
+ aioclient_mock.get(
+ "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
+ exc=aiohttp.ClientError,
+ )
+
+ def async_register_mock_platform(
+ hass: HomeAssistant, register: system_health.SystemHealthRegistration
+ ) -> None:
+ async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
+ return {}
+
+ register.async_register_info(mock_empty_info, "/config/mock_integration")
+
+ mock_platform(
+ hass,
+ "mock_no_info_integration.system_health",
+ MagicMock(async_register=async_register_mock_platform),
+ )
+ hass.config.components.add("mock_no_info_integration")
+
+ assert await async_setup_component(hass, "system_health", {})
+
+ with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
+ hexmock.return_value = "12345678901234567890"
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ "user_pool_id": "AAAA",
+ "region": "us-east-1",
+ "acme_server": "cert-server",
+ "relayer_server": "cloud.bla.com",
+ },
+ },
+ )
+ await hass.async_block_till_done()
+
+ await cloud.login("test-user", "test-pass")
+
+ cloud.remote.snitun_server = "us-west-1"
+ cloud.remote.certificate_status = CertificateStatus.READY
+ cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
+
+ await cloud.client.async_system_message({"region": "xx-earth-616"})
+ await set_cloud_prefs(
+ {
+ "alexa_enabled": True,
+ "google_enabled": False,
+ "remote_enabled": True,
+ "cloud_ice_servers_enabled": True,
+ }
+ )
+
+ now = dt_util.utcnow()
+ tz = now.astimezone().tzinfo
+ freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
+ logging.getLogger("hass_nabucasa.iot").info(
+ "This message will be dropped since this test patches MAX_RECORDS"
+ )
+ logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
+ logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
+ logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
+ freezer.move_to(now)
+
+ cloud_client = await hass_client()
+ with (
+ patch.object(hass.config, "config_dir", new="config"),
+ patch(
+ "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
+ return_value={
+ "installation_type": "Home Assistant Core",
+ "version": "2025.2.0",
+ "dev": False,
+ "hassio": False,
+ "virtualenv": False,
+ "python_version": "3.13.1",
+ "docker": False,
+ "container_arch": None,
+ "arch": "x86_64",
+ "timezone": "US/Pacific",
+ "os_name": "Linux",
+ "os_version": "6.12.9",
+ "user": "hass",
+ },
+ ),
+ patch(
+ "homeassistant.components.cloud.http_api.async_get_custom_components",
+ side_effect=Exception("Custom components error"),
+ ),
+ ):
+ req = await cloud_client.get("/api/cloud/support_package")
+ assert req.status == HTTPStatus.OK
+ assert await req.text() == snapshot
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_download_support_package_integration_load_error(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test download support package when async_get_loaded_integration fails."""
+
+ aioclient_mock.get("https://cloud.bla.com/status", text="")
+ aioclient_mock.get(
+ "https://cert-server/directory", exc=Exception("Unexpected exception")
+ )
+ aioclient_mock.get(
+ "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
+ exc=aiohttp.ClientError,
+ )
+
+ def async_register_mock_platform(
+ hass: HomeAssistant, register: system_health.SystemHealthRegistration
+ ) -> None:
+ async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
+ return {}
+
+ register.async_register_info(mock_empty_info, "/config/mock_integration")
+
+ mock_platform(
+ hass,
+ "mock_no_info_integration.system_health",
+ MagicMock(async_register=async_register_mock_platform),
+ )
+ hass.config.components.add("mock_no_info_integration")
+ # Add a component that will fail to load integration info
+ hass.config.components.add("test") # This is a custom integration from the fixture
+ hass.config.components.add("failing_integration")
+
+ assert await async_setup_component(hass, "system_health", {})
+
+ with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
+ hexmock.return_value = "12345678901234567890"
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ "user_pool_id": "AAAA",
+ "region": "us-east-1",
+ "acme_server": "cert-server",
+ "relayer_server": "cloud.bla.com",
+ },
+ },
+ )
+ await hass.async_block_till_done()
+
+ await cloud.login("test-user", "test-pass")
+
+ cloud.remote.snitun_server = "us-west-1"
+ cloud.remote.certificate_status = CertificateStatus.READY
+ cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
+
+ await cloud.client.async_system_message({"region": "xx-earth-616"})
+ await set_cloud_prefs(
+ {
+ "alexa_enabled": True,
+ "google_enabled": False,
+ "remote_enabled": True,
+ "cloud_ice_servers_enabled": True,
+ }
+ )
+
+ now = dt_util.utcnow()
+ tz = now.astimezone().tzinfo
+ freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
+ logging.getLogger("hass_nabucasa.iot").info(
+ "This message will be dropped since this test patches MAX_RECORDS"
+ )
+ logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
+ logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
+ logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
+ freezer.move_to(now)
+
+ cloud_client = await hass_client()
+ with (
+ patch.object(hass.config, "config_dir", new="config"),
+ patch(
+ "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
+ return_value={
+ "installation_type": "Home Assistant Core",
+ "version": "2025.2.0",
+ "dev": False,
+ "hassio": False,
+ "virtualenv": False,
+ "python_version": "3.13.1",
+ "docker": False,
+ "container_arch": None,
+ "arch": "x86_64",
+ "timezone": "US/Pacific",
+ "os_name": "Linux",
+ "os_version": "6.12.9",
+ "user": "hass",
+ },
+ ),
+ patch(
+ "homeassistant.components.cloud.http_api.async_get_loaded_integration",
+ side_effect=lambda hass, domain: Exception("Integration load error")
+ if domain == "failing_integration"
+ else async_get_loaded_integration(hass, domain),
+ ),
+ ):
+ req = await cloud_client.get("/api/cloud/support_package")
+ assert req.status == HTTPStatus.OK
+ assert await req.text() == snapshot
diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py
index 9a6d4abfc93..a12411b1eb2 100644
--- a/tests/components/cloud/test_init.py
+++ b/tests/components/cloud/test_init.py
@@ -44,7 +44,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None:
"region": "test-region",
"relayer_server": "test-relayer-server",
"accounts_server": "test-acounts-server",
- "cloudhook_server": "test-cloudhook-server",
"acme_server": "test-acme-server",
"remotestate_server": "test-remotestate-server",
},
@@ -60,7 +59,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None:
assert cl.relayer_server == "test-relayer-server"
assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket"
assert cl.accounts_server == "test-acounts-server"
- assert cl.cloudhook_server == "test-cloudhook-server"
assert cl.acme_server == "test-acme-server"
assert cl.remotestate_server == "test-remotestate-server"
diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py
index ba45e6bca57..45c199421d6 100644
--- a/tests/components/cloud/test_subscription.py
+++ b/tests/components/cloud/test_subscription.py
@@ -1,6 +1,7 @@
"""Test cloud subscription functions."""
-from unittest.mock import AsyncMock, Mock
+import asyncio
+from unittest.mock import AsyncMock, Mock, patch
from hass_nabucasa import Cloud, payments_api
import pytest
@@ -30,19 +31,35 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud:
)
+async def test_fetching_subscription_with_api_error(
+ aioclient_mock: AiohttpClientMocker,
+ caplog: pytest.LogCaptureFixture,
+ mocked_cloud: Cloud,
+) -> None:
+ """Test that we handle API errors."""
+ mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError(
+ "There was an error with the API"
+ )
+
+ assert await async_subscription_info(mocked_cloud) is None
+ assert (
+ "Failed to fetch subscription information - There was an error with the API"
+ in caplog.text
+ )
+
+
async def test_fetching_subscription_with_timeout_error(
aioclient_mock: AiohttpClientMocker,
caplog: pytest.LogCaptureFixture,
mocked_cloud: Cloud,
) -> None:
"""Test that we handle timeout error."""
- mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError(
- "Timeout reached while calling API"
- )
+ mocked_cloud.payments.subscription_info = lambda: asyncio.sleep(1)
+ with patch("homeassistant.components.cloud.subscription.REQUEST_TIMEOUT", 0):
+ assert await async_subscription_info(mocked_cloud) is None
- assert await async_subscription_info(mocked_cloud) is None
assert (
- "Failed to fetch subscription information - Timeout reached while calling API"
+ "A timeout of 0 was reached while trying to fetch subscription information"
in caplog.text
)
diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py
index 394db24347b..54d132fdc76 100644
--- a/tests/components/co2signal/__init__.py
+++ b/tests/components/co2signal/__init__.py
@@ -1,19 +1,19 @@
"""Tests for the CO2 Signal integration."""
-from aioelectricitymaps.models import (
- CarbonIntensityData,
- CarbonIntensityResponse,
- CarbonIntensityUnit,
+from aioelectricitymaps import HomeAssistantCarbonIntensityResponse
+from aioelectricitymaps.models.home_assistant import (
+ HomeAssistantCarbonIntensityData,
+ HomeAssistantCarbonIntensityUnit,
)
-VALID_RESPONSE = CarbonIntensityResponse(
+VALID_RESPONSE = HomeAssistantCarbonIntensityResponse(
status="ok",
country_code="FR",
- data=CarbonIntensityData(
+ data=HomeAssistantCarbonIntensityData(
carbon_intensity=45.98623190095805,
fossil_fuel_percentage=5.461182741937103,
),
- units=CarbonIntensityUnit(
+ units=HomeAssistantCarbonIntensityUnit(
carbon_intensity="gCO2eq/kWh",
),
)
diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py
index 680465c2537..48b3cf35e64 100644
--- a/tests/components/co2signal/conftest.py
+++ b/tests/components/co2signal/conftest.py
@@ -30,8 +30,7 @@ def mock_electricity_maps() -> Generator[MagicMock]:
),
):
client = electricity_maps.return_value
- client.latest_carbon_intensity_by_coordinates.return_value = VALID_RESPONSE
- client.latest_carbon_intensity_by_country_code.return_value = VALID_RESPONSE
+ client.carbon_intensity_for_home_assistant.return_value = VALID_RESPONSE
yield client
diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py
index f8f94d44126..6c8b6a977fa 100644
--- a/tests/components/co2signal/test_config_flow.py
+++ b/tests/components/co2signal/test_config_flow.py
@@ -157,8 +157,7 @@ async def test_form_error_handling(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
- electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = side_effect
- electricity_maps.latest_carbon_intensity_by_country_code.side_effect = side_effect
+ electricity_maps.carbon_intensity_for_home_assistant.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -172,8 +171,7 @@ async def test_form_error_handling(
assert result["errors"] == {"base": err_code}
# reset mock and test if now succeeds
- electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None
- electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None
+ electricity_maps.carbon_intensity_for_home_assistant.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py
index 2154782f62d..16c9763a222 100644
--- a/tests/components/co2signal/test_sensor.py
+++ b/tests/components/co2signal/test_sensor.py
@@ -62,8 +62,7 @@ async def test_sensor_update_fail(
assert state.state == "45.9862319009581"
assert len(electricity_maps.mock_calls) == 1
- electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = error
- electricity_maps.latest_carbon_intensity_by_country_code.side_effect = error
+ electricity_maps.carbon_intensity_for_home_assistant.side_effect = error
freezer.tick(timedelta(minutes=20))
async_fire_time_changed(hass)
@@ -74,8 +73,7 @@ async def test_sensor_update_fail(
assert len(electricity_maps.mock_calls) == 2
# reset mock and test if entity is available again
- electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = None
- electricity_maps.latest_carbon_intensity_by_country_code.side_effect = None
+ electricity_maps.carbon_intensity_for_home_assistant.side_effect = None
freezer.tick(timedelta(minutes=20))
async_fire_time_changed(hass)
@@ -96,10 +94,7 @@ async def test_sensor_reauth_triggered(
assert (state := hass.states.get("sensor.electricity_maps_co2_intensity"))
assert state.state == "45.9862319009581"
- electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = (
- ElectricityMapsInvalidTokenError
- )
- electricity_maps.latest_carbon_intensity_by_country_code.side_effect = (
+ electricity_maps.carbon_intensity_for_home_assistant.side_effect = (
ElectricityMapsInvalidTokenError
)
diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py
index 0cbdaf56bbe..f275c192dd4 100644
--- a/tests/components/comelit/const.py
+++ b/tests/components/comelit/const.py
@@ -20,14 +20,27 @@ from aiocomelit.const import (
BRIDGE_HOST = "fake_bridge_host"
BRIDGE_PORT = 80
-BRIDGE_PIN = 1234
+BRIDGE_PIN = "1234"
VEDO_HOST = "fake_vedo_host"
VEDO_PORT = 8080
-VEDO_PIN = 5678
+VEDO_PIN = "5678"
-FAKE_PIN = 0000
+FAKE_PIN = "0000"
+BAD_PIN = "abcd"
+LIGHT0 = ComelitSerialBridgeObject(
+ index=0,
+ name="Light0",
+ status=0,
+ human_status="off",
+ type="light",
+ val=0,
+ protected=0,
+ zone="Bathroom",
+ power=0.0,
+ power_unit=WATT,
+)
BRIDGE_DEVICE_QUERY = {
CLIMATE: {
0: ComelitSerialBridgeObject(
@@ -62,18 +75,7 @@ BRIDGE_DEVICE_QUERY = {
)
},
LIGHT: {
- 0: ComelitSerialBridgeObject(
- index=0,
- name="Light0",
- status=0,
- human_status="off",
- type="light",
- val=0,
- protected=0,
- zone="Bathroom",
- power=0.0,
- power_unit=WATT,
- )
+ 0: LIGHT0,
},
OTHER: {
0: ComelitSerialBridgeObject(
@@ -93,6 +95,13 @@ BRIDGE_DEVICE_QUERY = {
SCENARIO: {},
}
+ZONE0 = ComelitVedoZoneObject(
+ index=0,
+ name="Zone0",
+ status_api="0x000",
+ status=0,
+ human_status=AlarmZoneState.REST,
+)
VEDO_DEVICE_QUERY = AlarmDataObject(
alarm_areas={
0: ComelitVedoAreaObject(
@@ -112,12 +121,6 @@ VEDO_DEVICE_QUERY = AlarmDataObject(
)
},
alarm_zones={
- 0: ComelitVedoZoneObject(
- index=0,
- name="Zone0",
- status_api="0x000",
- status=0,
- human_status=AlarmZoneState.REST,
- )
+ 0: ZONE0,
},
)
diff --git a/tests/components/comelit/test_alarm_control_panel.py b/tests/components/comelit/test_alarm_control_panel.py
index d3feac6ad3b..345c8c4df56 100644
--- a/tests/components/comelit/test_alarm_control_panel.py
+++ b/tests/components/comelit/test_alarm_control_panel.py
@@ -2,8 +2,8 @@
from unittest.mock import AsyncMock
-from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject
-from aiocomelit.const import AlarmAreaState, AlarmZoneState
+from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject
+from aiocomelit.const import AlarmAreaState
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from . import setup_integration
-from .const import VEDO_PIN
+from .const import VEDO_PIN, ZONE0
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -74,13 +74,7 @@ async def test_entity_availability(
)
},
alarm_zones={
- 0: ComelitVedoZoneObject(
- index=0,
- name="Zone0",
- status_api="0x000",
- status=0,
- human_status=AlarmZoneState.REST,
- )
+ 0: ZONE0,
},
)
diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py
index 1751a837026..90622bbe457 100644
--- a/tests/components/comelit/test_config_flow.py
+++ b/tests/components/comelit/test_config_flow.py
@@ -10,9 +10,10 @@ from homeassistant.components.comelit.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.data_entry_flow import FlowResultType, InvalidData
from .const import (
+ BAD_PIN,
BRIDGE_HOST,
BRIDGE_PIN,
BRIDGE_PORT,
@@ -310,3 +311,46 @@ async def test_reconfigure_fails(
CONF_PIN: BRIDGE_PIN,
CONF_TYPE: BRIDGE,
}
+
+
+async def test_pin_format_serial_bridge(
+ hass: HomeAssistant,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test PIN is valid format."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with pytest.raises(InvalidData):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_HOST: BRIDGE_HOST,
+ CONF_PORT: BRIDGE_PORT,
+ CONF_PIN: BAD_PIN,
+ },
+ )
+ assert result["type"] is FlowResultType.FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_HOST: BRIDGE_HOST,
+ CONF_PORT: BRIDGE_PORT,
+ CONF_PIN: BRIDGE_PIN,
+ },
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == {
+ CONF_HOST: BRIDGE_HOST,
+ CONF_PORT: BRIDGE_PORT,
+ CONF_PIN: BRIDGE_PIN,
+ CONF_TYPE: BRIDGE,
+ }
+ assert not result["result"].unique_id
+ await hass.async_block_till_done()
diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py
index 49e3164e875..d38e8bc7810 100644
--- a/tests/components/comelit/test_coordinator.py
+++ b/tests/components/comelit/test_coordinator.py
@@ -2,6 +2,23 @@
from unittest.mock import AsyncMock
+from aiocomelit.api import (
+ AlarmDataObject,
+ ComelitSerialBridgeObject,
+ ComelitVedoAreaObject,
+ ComelitVedoZoneObject,
+)
+from aiocomelit.const import (
+ CLIMATE,
+ COVER,
+ IRRIGATION,
+ LIGHT,
+ OTHER,
+ SCENARIO,
+ WATT,
+ AlarmAreaState,
+ AlarmZoneState,
+)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -11,6 +28,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from . import setup_integration
+from .const import LIGHT0, ZONE0
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -47,3 +65,145 @@ async def test_coordinator_data_update_fails(
assert (state := hass.states.get(entity_id))
assert state.state == STATE_UNAVAILABLE
+
+
+async def test_coordinator_stale_device_serial_bridge(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test coordinator data update removes stale Serial Brdige devices."""
+
+ entity_id_0 = "light.light0"
+ entity_id_1 = "light.light1"
+
+ mock_serial_bridge.get_all_devices.return_value = {
+ CLIMATE: {},
+ COVER: {},
+ LIGHT: {
+ 0: LIGHT0,
+ 1: ComelitSerialBridgeObject(
+ index=1,
+ name="Light1",
+ status=0,
+ human_status="off",
+ type="light",
+ val=0,
+ protected=0,
+ zone="Bathroom",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ },
+ OTHER: {},
+ IRRIGATION: {},
+ SCENARIO: {},
+ }
+
+ await setup_integration(hass, mock_serial_bridge_config_entry)
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == STATE_OFF
+ assert (state := hass.states.get(entity_id_1))
+ assert state.state == STATE_OFF
+
+ mock_serial_bridge.get_all_devices.return_value = {
+ CLIMATE: {},
+ COVER: {},
+ LIGHT: {0: LIGHT0},
+ OTHER: {},
+ IRRIGATION: {},
+ SCENARIO: {},
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == STATE_OFF
+
+ # Light1 is removed
+ assert not hass.states.get(entity_id_1)
+
+
+async def test_coordinator_stale_device_vedo(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_vedo: AsyncMock,
+ mock_vedo_config_entry: MockConfigEntry,
+) -> None:
+ """Test coordinator data update removes stale VEDO devices."""
+
+ entity_id_0 = "sensor.zone0"
+ entity_id_1 = "sensor.zone1"
+
+ mock_vedo.get_all_areas_and_zones.return_value = AlarmDataObject(
+ alarm_areas={
+ 0: ComelitVedoAreaObject(
+ index=0,
+ name="Area0",
+ p1=True,
+ p2=True,
+ ready=False,
+ armed=0,
+ alarm=False,
+ alarm_memory=False,
+ sabotage=False,
+ anomaly=False,
+ in_time=False,
+ out_time=False,
+ human_status=AlarmAreaState.DISARMED,
+ )
+ },
+ alarm_zones={
+ 0: ZONE0,
+ 1: ComelitVedoZoneObject(
+ index=1,
+ name="Zone1",
+ status_api="0x000",
+ status=0,
+ human_status=AlarmZoneState.REST,
+ ),
+ },
+ )
+ await setup_integration(hass, mock_vedo_config_entry)
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == AlarmZoneState.REST.value
+ assert (state := hass.states.get(entity_id_1))
+ assert state.state == AlarmZoneState.REST.value
+
+ mock_vedo.get_all_areas_and_zones.return_value = AlarmDataObject(
+ alarm_areas={
+ 0: ComelitVedoAreaObject(
+ index=0,
+ name="Area0",
+ p1=True,
+ p2=True,
+ ready=False,
+ armed=0,
+ alarm=False,
+ alarm_memory=False,
+ sabotage=False,
+ anomaly=False,
+ in_time=False,
+ out_time=False,
+ human_status=AlarmAreaState.DISARMED,
+ )
+ },
+ alarm_zones={
+ 0: ZONE0,
+ },
+ )
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get(entity_id_0))
+ assert state.state == AlarmZoneState.REST.value
+
+ # Zone1 is removed
+ assert not hass.states.get(entity_id_1)
diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py
index 5513f3c4e25..02efff1dd94 100644
--- a/tests/components/comelit/test_cover.py
+++ b/tests/components/comelit/test_cover.py
@@ -193,3 +193,53 @@ async def test_cover_restore_state(
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OPENING
+
+
+async def test_cover_dynamic(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test cover dynamically added."""
+
+ mock_serial_bridge.reset_mock()
+ await setup_integration(hass, mock_serial_bridge_config_entry)
+
+ assert hass.states.get(ENTITY_ID)
+
+ entity_id_2 = "cover.cover1"
+
+ mock_serial_bridge.get_all_devices.return_value[COVER] = {
+ 0: ComelitSerialBridgeObject(
+ index=0,
+ name="Cover0",
+ status=0,
+ human_status="stopped",
+ type="cover",
+ val=0,
+ protected=0,
+ zone="Open space",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ 1: ComelitSerialBridgeObject(
+ index=1,
+ name="Cover1",
+ status=0,
+ human_status="stopped",
+ type="cover",
+ val=0,
+ protected=0,
+ zone="Open space",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(ENTITY_ID)
+ assert hass.states.get(entity_id_2)
diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py
index 36a191c9ee3..af2ff22a380 100644
--- a/tests/components/comelit/test_light.py
+++ b/tests/components/comelit/test_light.py
@@ -2,9 +2,13 @@
from unittest.mock import AsyncMock, patch
+from aiocomelit.api import ComelitSerialBridgeObject
+from aiocomelit.const import LIGHT, WATT
+from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.comelit.const import SCAN_INTERVAL
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
SERVICE_TOGGLE,
@@ -17,7 +21,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from tests.common import MockConfigEntry, snapshot_platform
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "light.light0"
@@ -74,3 +78,53 @@ async def test_light_set_state(
assert (state := hass.states.get(ENTITY_ID))
assert state.state == status
+
+
+async def test_light_dynamic(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test light dynamically added."""
+
+ mock_serial_bridge.reset_mock()
+ await setup_integration(hass, mock_serial_bridge_config_entry)
+
+ assert hass.states.get(ENTITY_ID)
+
+ entity_id_2 = "light.light1"
+
+ mock_serial_bridge.get_all_devices.return_value[LIGHT] = {
+ 0: ComelitSerialBridgeObject(
+ index=0,
+ name="Light0",
+ status=0,
+ human_status="stopped",
+ type="light",
+ val=0,
+ protected=0,
+ zone="Open space",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ 1: ComelitSerialBridgeObject(
+ index=1,
+ name="Light1",
+ status=0,
+ human_status="stopped",
+ type="light",
+ val=0,
+ protected=0,
+ zone="Open space",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(ENTITY_ID)
+ assert hass.states.get(entity_id_2)
diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py
index 1bf717ca894..eb9adc0d81e 100644
--- a/tests/components/comelit/test_sensor.py
+++ b/tests/components/comelit/test_sensor.py
@@ -2,8 +2,13 @@
from unittest.mock import AsyncMock, patch
-from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject
-from aiocomelit.const import AlarmAreaState, AlarmZoneState
+from aiocomelit.api import (
+ AlarmDataObject,
+ ComelitSerialBridgeObject,
+ ComelitVedoAreaObject,
+ ComelitVedoZoneObject,
+)
+from aiocomelit.const import OTHER, WATT, AlarmAreaState, AlarmZoneState
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
@@ -44,7 +49,7 @@ async def test_sensor_state_unknown(
mock_vedo: AsyncMock,
mock_vedo_config_entry: MockConfigEntry,
) -> None:
- """Test sensor unknown state."""
+ """Test VEDO sensor unknown state."""
await setup_integration(hass, mock_vedo_config_entry)
@@ -88,3 +93,93 @@ async def test_sensor_state_unknown(
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNKNOWN
+
+
+async def test_serial_bridge_sensor_dynamic(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test Serial Bridge sensor dynamically added."""
+
+ mock_serial_bridge.reset_mock()
+ await setup_integration(hass, mock_serial_bridge_config_entry)
+
+ entity_id = "sensor.switch0"
+ entity_id_2 = "sensor.switch1"
+ assert hass.states.get(entity_id)
+
+ mock_serial_bridge.get_all_devices.return_value[OTHER] = {
+ 0: ComelitSerialBridgeObject(
+ index=0,
+ name="Switch0",
+ status=0,
+ human_status="off",
+ type="other",
+ val=0,
+ protected=0,
+ zone="Bathroom",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ 1: ComelitSerialBridgeObject(
+ index=1,
+ name="Switch1",
+ status=0,
+ human_status="off",
+ type="other",
+ val=0,
+ protected=0,
+ zone="Bathroom",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(entity_id)
+ assert hass.states.get(entity_id_2)
+
+
+async def test_vedo_sensor_dynamic(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_vedo: AsyncMock,
+ mock_vedo_config_entry: MockConfigEntry,
+) -> None:
+ """Test VEDO sensor dynamically added."""
+
+ mock_vedo.reset_mock()
+ await setup_integration(hass, mock_vedo_config_entry)
+
+ assert hass.states.get(ENTITY_ID)
+
+ entity_id_2 = "sensor.zone1"
+
+ mock_vedo.get_all_areas_and_zones.return_value["alarm_zones"] = {
+ 0: ComelitVedoZoneObject(
+ index=0,
+ name="Zone0",
+ status_api="0x000",
+ status=0,
+ human_status=AlarmZoneState.REST,
+ ),
+ 1: ComelitVedoZoneObject(
+ index=1,
+ name="Zone1",
+ status_api="0x000",
+ status=0,
+ human_status=AlarmZoneState.REST,
+ ),
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(ENTITY_ID)
+ assert hass.states.get(entity_id_2)
diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py
index 31a4c4b144c..38955bfad40 100644
--- a/tests/components/comelit/test_switch.py
+++ b/tests/components/comelit/test_switch.py
@@ -2,9 +2,13 @@
from unittest.mock import AsyncMock, patch
+from aiocomelit.api import ComelitSerialBridgeObject
+from aiocomelit.const import IRRIGATION, WATT
+from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
+from homeassistant.components.comelit.const import SCAN_INTERVAL
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TOGGLE,
@@ -17,7 +21,7 @@ from homeassistant.helpers import entity_registry as er
from . import setup_integration
-from tests.common import MockConfigEntry, snapshot_platform
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "switch.switch0"
@@ -74,3 +78,53 @@ async def test_switch_set_state(
assert (state := hass.states.get(ENTITY_ID))
assert state.state == status
+
+
+async def test_switch_dynamic(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+ mock_serial_bridge: AsyncMock,
+ mock_serial_bridge_config_entry: MockConfigEntry,
+) -> None:
+ """Test switch dynamically added."""
+
+ mock_serial_bridge.reset_mock()
+ await setup_integration(hass, mock_serial_bridge_config_entry)
+
+ entity_id = "switch.switch0"
+ entity_id_2 = "switch.switch1"
+ assert hass.states.get(entity_id)
+
+ mock_serial_bridge.get_all_devices.return_value[IRRIGATION] = {
+ 0: ComelitSerialBridgeObject(
+ index=0,
+ name="Switch0",
+ status=0,
+ human_status="off",
+ type="irrigation",
+ val=0,
+ protected=0,
+ zone="Terrace",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ 1: ComelitSerialBridgeObject(
+ index=1,
+ name="Switch1",
+ status=0,
+ human_status="off",
+ type="irrigation",
+ val=0,
+ protected=0,
+ zone="Terrace",
+ power=0.0,
+ power_unit=WATT,
+ ),
+ }
+
+ freezer.tick(SCAN_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(entity_id)
+ assert hass.states.get(entity_id_2)
diff --git a/tests/components/compit/__init__.py b/tests/components/compit/__init__.py
new file mode 100644
index 00000000000..a817df77ad0
--- /dev/null
+++ b/tests/components/compit/__init__.py
@@ -0,0 +1 @@
+"""Tests for the compit component."""
diff --git a/tests/components/compit/conftest.py b/tests/components/compit/conftest.py
new file mode 100644
index 00000000000..e8e4b09d9be
--- /dev/null
+++ b/tests/components/compit/conftest.py
@@ -0,0 +1,41 @@
+"""Common fixtures for the Compit tests."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from homeassistant.components.compit.const import DOMAIN
+from homeassistant.const import CONF_EMAIL
+
+from .consts import CONFIG_INPUT
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_config_entry():
+ """Return a mock config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data=CONFIG_INPUT,
+ unique_id=CONFIG_INPUT[CONF_EMAIL],
+ )
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override async_setup_entry."""
+ with patch(
+ "homeassistant.components.compit.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_compit_api() -> Generator[AsyncMock]:
+ """Mock CompitApiConnector."""
+ with patch(
+ "homeassistant.components.compit.config_flow.CompitApiConnector.init",
+ ) as mock_api:
+ yield mock_api
diff --git a/tests/components/compit/consts.py b/tests/components/compit/consts.py
new file mode 100644
index 00000000000..4a8e3884fbd
--- /dev/null
+++ b/tests/components/compit/consts.py
@@ -0,0 +1,8 @@
+"""Constants for the Compit component tests."""
+
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+
+CONFIG_INPUT = {
+ CONF_EMAIL: "test@example.com",
+ CONF_PASSWORD: "password",
+}
diff --git a/tests/components/compit/test_config_flow.py b/tests/components/compit/test_config_flow.py
new file mode 100644
index 00000000000..2305187e000
--- /dev/null
+++ b/tests/components/compit/test_config_flow.py
@@ -0,0 +1,158 @@
+"""Test the Compit config flow."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.compit.config_flow import CannotConnect, InvalidAuth
+from homeassistant.components.compit.const import DOMAIN
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .consts import CONFIG_INPUT
+
+from tests.common import MockConfigEntry
+
+
+async def test_async_step_user_success(
+ hass: HomeAssistant, mock_compit_api: AsyncMock, mock_setup_entry: AsyncMock
+) -> None:
+ """Test user step with successful authentication."""
+ mock_compit_api.return_value = True
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == config_entries.SOURCE_USER
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG_INPUT
+ )
+
+ assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["title"] == CONFIG_INPUT[CONF_EMAIL]
+ assert result["data"] == CONFIG_INPUT
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ ("exception", "expected_error"),
+ [
+ (InvalidAuth(), "invalid_auth"),
+ (CannotConnect(), "cannot_connect"),
+ (Exception(), "unknown"),
+ (False, "unknown"),
+ ],
+)
+async def test_async_step_user_failed_auth(
+ hass: HomeAssistant,
+ exception: Exception,
+ expected_error: str,
+ mock_compit_api: AsyncMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test user step with invalid authentication then success after error is cleared."""
+ mock_compit_api.side_effect = [exception, True]
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == config_entries.SOURCE_USER
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG_INPUT
+ )
+
+ assert result["type"] == FlowResultType.FORM
+ assert result["errors"] == {"base": expected_error}
+
+ # Test success after error is cleared
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG_INPUT
+ )
+ assert result["type"] == FlowResultType.CREATE_ENTRY
+ assert result["title"] == CONFIG_INPUT[CONF_EMAIL]
+ assert result["data"] == CONFIG_INPUT
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_async_step_reauth_success(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_compit_api: AsyncMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test reauth step with successful authentication."""
+ mock_compit_api.return_value = True
+
+ mock_config_entry.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result["step_id"] == "reauth_confirm"
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_PASSWORD: "new-password"}
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ assert mock_config_entry.data == {
+ CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL],
+ CONF_PASSWORD: "new-password",
+ }
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ ("exception", "expected_error"),
+ [
+ (InvalidAuth(), "invalid_auth"),
+ (CannotConnect(), "cannot_connect"),
+ (Exception(), "unknown"),
+ ],
+)
+async def test_async_step_reauth_confirm_failed_auth(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ exception: Exception,
+ expected_error: str,
+ mock_compit_api: AsyncMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test reauth confirm step with invalid authentication then success after error is cleared."""
+ mock_compit_api.side_effect = [exception, True]
+
+ mock_config_entry.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result["step_id"] == "reauth_confirm"
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_PASSWORD: "new-password"}
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": expected_error}
+
+ # Test success after error is cleared
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL], CONF_PASSWORD: "correct-password"},
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ assert mock_config_entry.data == {
+ CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL],
+ CONF_PASSWORD: "correct-password",
+ }
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
index 8f89549944c..17703c0958b 100644
--- a/tests/components/config/test_config_entries.py
+++ b/tests/components/config/test_config_entries.py
@@ -1122,6 +1122,134 @@ async def test_get_progress_subscribe_in_progress(
}
+async def test_get_progress_subscribe_in_progress_bad_flow(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test querying for the flows that are in progress."""
+ assert await async_setup_component(hass, "config", {})
+ mock_platform(hass, "test.config_flow", None)
+ mock_platform(hass, "test2.config_flow", None)
+ ws_client = await hass_ws_client(hass)
+
+ mock_integration(
+ hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True))
+ )
+
+ entry = MockConfigEntry(domain="test", title="Test", entry_id="1234")
+ entry.add_to_hass(hass)
+
+ class TestFlow(core_ce.ConfigFlow):
+ VERSION = 5
+
+ async def async_step_bluetooth(
+ self, discovery_info: HassioServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a bluetooth discovery."""
+ return self.async_abort(reason="already_configured")
+
+ async def async_step_hassio(
+ self, discovery_info: HassioServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a Hass.io discovery."""
+ return await self.async_step_account()
+
+ async def async_step_account(self, user_input: dict[str, Any] | None = None):
+ """Show a form to the user."""
+ return self.async_show_form(step_id="account")
+
+ async def async_step_user(self, user_input: dict[str, Any] | None = None):
+ """Handle a config flow initialized by the user."""
+ return await self.async_step_account()
+
+ async def async_step_reauth(self, user_input: dict[str, Any] | None = None):
+ """Handle a reauthentication flow."""
+ nonlocal entry
+ assert self._get_reauth_entry() is entry
+ return await self.async_step_account()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ):
+ """Handle a reconfiguration flow initialized by the user."""
+ nonlocal entry
+ assert self._get_reconfigure_entry() is entry
+ return await self.async_step_account()
+
+ class BadFlow(core_ce.ConfigFlow):
+ VERSION = 1
+
+ async def async_step_account(self, user_input: dict[str, Any] | None = None):
+ """Show a form to the user."""
+ return self.async_show_form(step_id="account")
+
+ async def async_step_reauth(self, user_input: dict[str, Any] | None = None):
+ """Handle a config flow initialized by the user."""
+ self.context["bad"] = self # This can't be serialized by the JSON encoder
+ return await self.async_step_account()
+
+ flow_context = {
+ "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH},
+ "hassio": {"source": core_ce.SOURCE_HASSIO},
+ "user": {"source": core_ce.SOURCE_USER},
+ "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"},
+ "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"},
+ }
+ forms = {}
+
+ with mock_config_flow("test", TestFlow):
+ for key, context in flow_context.items():
+ forms[key] = await hass.config_entries.flow.async_init(
+ "test", context=context
+ )
+
+ assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT
+ for key in ("hassio", "user", "reauth", "reconfigure"):
+ assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM
+ assert forms[key]["step_id"] == "account"
+
+ with mock_config_flow("test2", BadFlow):
+ forms["bad"] = await hass.config_entries.flow.async_init(
+ "test2", context={"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}
+ )
+ assert forms["bad"]["type"] == data_entry_flow.FlowResultType.FORM
+ assert forms["bad"]["step_id"] == "account"
+
+ await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"})
+
+ # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE
+ # should be filtered out
+ responses = []
+ responses.append(await ws_client.receive_json())
+ assert responses == [
+ {
+ "event": unordered(
+ [
+ {
+ "flow": {
+ "flow_id": forms[key]["flow_id"],
+ "handler": "test",
+ "step_id": "account",
+ "context": flow_context[key],
+ },
+ "flow_id": forms[key]["flow_id"],
+ "type": None,
+ }
+ for key in ("hassio", "reauth")
+ ]
+ ),
+ "id": 1,
+ "type": "event",
+ }
+ ]
+
+ response = await ws_client.receive_json()
+ assert response == {"id": ANY, "result": None, "success": True, "type": "result"}
+
+ assert "Unable to serialize to JSON. Bad data found at $.context.bad" in caplog.text
+
+
async def test_get_progress_subscribe_unauth(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser
) -> None:
@@ -3351,6 +3479,82 @@ async def test_list_subentries(
}
+async def test_update_subentry(
+ hass: HomeAssistant, hass_ws_client: WebSocketGenerator
+) -> None:
+ """Test that we can update a subentry."""
+ assert await async_setup_component(hass, "config", {})
+ ws_client = await hass_ws_client(hass)
+
+ entry = MockConfigEntry(
+ domain="test",
+ state=core_ce.ConfigEntryState.LOADED,
+ subentries_data=[
+ core_ce.ConfigSubentryData(
+ data={"test": "test"},
+ subentry_id="mock_id",
+ subentry_type="test",
+ title="Mock title",
+ unique_id="mock_unique_id",
+ )
+ ],
+ )
+ entry.add_to_hass(hass)
+
+ await ws_client.send_json_auto_id(
+ {
+ "type": "config_entries/subentries/update",
+ "entry_id": entry.entry_id,
+ "subentry_id": "mock_id",
+ "title": "Updated Title",
+ }
+ )
+ response = await ws_client.receive_json()
+
+ assert response["success"]
+ assert response["result"] is None
+
+ assert list(entry.subentries.values())[0].title == "Updated Title"
+ assert list(entry.subentries.values())[0].unique_id == "mock_unique_id"
+ assert list(entry.subentries.values())[0].data["test"] == "test"
+
+ # Try renaming subentry from an unknown entry
+ ws_client = await hass_ws_client(hass)
+ await ws_client.send_json_auto_id(
+ {
+ "type": "config_entries/subentries/update",
+ "entry_id": "no_such_entry",
+ "subentry_id": "mock_id",
+ "title": "Updated Title",
+ }
+ )
+ response = await ws_client.receive_json()
+
+ assert not response["success"]
+ assert response["error"] == {
+ "code": "not_found",
+ "message": "Config entry not found",
+ }
+
+ # Try renaming subentry from an unknown subentry
+ ws_client = await hass_ws_client(hass)
+ await ws_client.send_json_auto_id(
+ {
+ "type": "config_entries/subentries/update",
+ "entry_id": entry.entry_id,
+ "subentry_id": "no_such_entry2",
+ "title": "Updated Title2",
+ }
+ )
+ response = await ws_client.receive_json()
+
+ assert not response["success"]
+ assert response["error"] == {
+ "code": "not_found",
+ "message": "Config subentry not found",
+ }
+
+
async def test_delete_subentry(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py
index 19d8434fc5a..f7d674769eb 100644
--- a/tests/components/conversation/conftest.py
+++ b/tests/components/conversation/conftest.py
@@ -6,6 +6,7 @@ from unittest.mock import Mock, patch
import pytest
from homeassistant.components import conversation
+from homeassistant.components.conversation import async_get_agent, default_agent
from homeassistant.components.shopping_list import intent as sl_intent
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant
@@ -43,6 +44,7 @@ def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInp
conversation_id=None,
agent_id="mock-agent-id",
device_id=None,
+ satellite_id=None,
language="en",
)
@@ -76,5 +78,6 @@ async def init_components(hass: HomeAssistant):
assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}})
# Disable fuzzy matching by default for tests
- agent = hass.data[conversation.DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
+ assert isinstance(agent, default_agent.DefaultAgent)
agent.fuzzy_matching = False
diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py
index 7c5e897d86c..8356274a41e 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -12,10 +12,14 @@ from syrupy.assertion import SnapshotAssertion
import yaml
from homeassistant.components import conversation, cover, media_player, weather
-from homeassistant.components.conversation import default_agent
-from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY
+from homeassistant.components.conversation import (
+ async_get_agent,
+ default_agent,
+ get_agent_manager,
+)
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
from homeassistant.components.conversation.models import ConversationInput
+from homeassistant.components.conversation.trigger import TriggerDetails
from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings,
@@ -87,7 +91,8 @@ async def init_components(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, "intent", {})
# Disable fuzzy matching by default for tests
- agent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
+ assert isinstance(agent, default_agent.DefaultAgent)
agent.fuzzy_matching = False
@@ -215,7 +220,7 @@ async def test_exposed_areas(
@pytest.mark.usefixtures("init_components")
async def test_conversation_agent(hass: HomeAssistant) -> None:
"""Test DefaultAgent."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
with patch(
"homeassistant.components.conversation.default_agent.get_languages",
return_value=["dwarvish", "elvish", "entish"],
@@ -231,6 +236,29 @@ async def test_conversation_agent(hass: HomeAssistant) -> None:
)
+@pytest.mark.usefixtures("init_components")
+async def test_punctuation(hass: HomeAssistant) -> None:
+ """Test punctuation is handled properly."""
+ hass.states.async_set(
+ "light.test_light",
+ "off",
+ attributes={ATTR_FRIENDLY_NAME: "Test light"},
+ )
+ expose_entity(hass, "light.test_light", True)
+
+ calls = async_mock_service(hass, "light", "turn_on")
+ result = await conversation.async_converse(
+ hass, "Turn?? on,, test;; light!!!", None, Context(), None
+ )
+
+ assert len(calls) == 1
+ assert calls[0].data["entity_id"][0] == "light.test_light"
+ assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert result.response.intent is not None
+ assert result.response.intent.slots["name"]["value"] == "test light"
+ assert result.response.intent.slots["name"]["text"] == "test light"
+
+
async def test_expose_flag_automatically_set(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -392,11 +420,10 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
trigger_sentences = ["It's party time", "It is time to party"]
trigger_response = "Cowabunga!"
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ manager = get_agent_manager(hass)
callback = AsyncMock(return_value=trigger_response)
- unregister = agent.register_trigger(trigger_sentences, callback)
+ unregister = manager.register_trigger(TriggerDetails(trigger_sentences, callback))
result = await conversation.async_converse(hass, "Not the trigger", None, Context())
assert result.response.response_type == intent.IntentResponseType.ERROR
@@ -439,8 +466,7 @@ async def test_trigger_sentence_response_translation(
"""Test translation of default response 'done'."""
hass.config.language = language
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ manager = get_agent_manager(hass)
translations = {
"en": {"component.conversation.conversation.agent.done": "English done"},
@@ -452,8 +478,8 @@ async def test_trigger_sentence_response_translation(
"homeassistant.components.conversation.default_agent.translation.async_get_translations",
return_value=translations.get(language),
):
- unregister = agent.register_trigger(
- ["test sentence"], AsyncMock(return_value=None)
+ unregister = manager.register_trigger(
+ TriggerDetails(["test sentence"], AsyncMock(return_value=None))
)
result = await conversation.async_converse(
hass, "test sentence", None, Context()
@@ -501,13 +527,13 @@ async def test_respond_intent(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("init_components")
-async def test_device_area_context(
+async def test_satellite_area_context(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
- """Test that including a device_id will target a specific area."""
+ """Test that including a satellite will target a specific area."""
turn_on_calls = async_mock_service(hass, "light", "turn_on")
turn_off_calls = async_mock_service(hass, "light", "turn_off")
@@ -539,12 +565,12 @@ async def test_device_area_context(
entry = MockConfigEntry()
entry.add_to_hass(hass)
- kitchen_satellite = device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- connections=set(),
- identifiers={("demo", "id-satellite-kitchen")},
+ kitchen_satellite = entity_registry.async_get_or_create(
+ "assist_satellite", "demo", "kitchen"
+ )
+ entity_registry.async_update_entity(
+ kitchen_satellite.entity_id, area_id=area_kitchen.id
)
- device_registry.async_update_device(kitchen_satellite.id, area_id=area_kitchen.id)
bedroom_satellite = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@@ -560,7 +586,7 @@ async def test_device_area_context(
None,
Context(),
None,
- device_id=kitchen_satellite.id,
+ satellite_id=kitchen_satellite.entity_id,
)
await hass.async_block_till_done()
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
@@ -584,7 +610,7 @@ async def test_device_area_context(
None,
Context(),
None,
- device_id=kitchen_satellite.id,
+ satellite_id=kitchen_satellite.entity_id,
)
await hass.async_block_till_done()
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
@@ -2502,8 +2528,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non
hass.states.async_set("cover.front_door", "closed")
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
result = await agent.async_process(
ConversationInput(
@@ -2511,12 +2536,13 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
)
assert len(calls) == 1
- assert result.response.speech["plain"]["speech"] == "Opened"
+ assert result.response.speech["plain"]["speech"] == "Opening"
async def test_turn_on_area(
@@ -2848,8 +2874,7 @@ async def test_query_same_name_different_areas(
@pytest.mark.usefixtures("init_components")
async def test_intent_cache_exposed(hass: HomeAssistant) -> None:
"""Test that intent recognition results are cached for exposed entities."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
entity_id = "light.test_light"
hass.states.async_set(entity_id, "off")
@@ -2861,6 +2886,7 @@ async def test_intent_cache_exposed(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -2887,8 +2913,7 @@ async def test_intent_cache_exposed(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("init_components")
async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
"""Test that intent recognition results are cached for all entities."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
entity_id = "light.test_light"
hass.states.async_set(entity_id, "off")
@@ -2900,6 +2925,7 @@ async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -2926,8 +2952,7 @@ async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("init_components")
async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None:
"""Test that intent recognition results are cached for fuzzy matches."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
# There is no entity named test light
user_input = ConversationInput(
@@ -2935,6 +2960,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -2955,8 +2981,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("init_components")
async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
"""Test that entities are filtered by the input text before intent matching."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
# Only the switch is exposed
hass.states.async_set("light.test_light", "off")
@@ -2977,6 +3002,7 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -3003,6 +3029,7 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -3136,13 +3163,14 @@ async def test_handle_intents_with_response_errors(
assert await async_setup_component(hass, "climate", {})
area_registry.async_create("living room")
- agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
user_input = ConversationInput(
text="What is the temperature in the living room?",
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -3173,13 +3201,14 @@ async def test_handle_intents_filters_results(
assert await async_setup_component(hass, "climate", {})
area_registry.async_create("living room")
- agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
user_input = ConversationInput(
text="What is the temperature in the living room?",
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
agent_id=None,
)
@@ -3332,7 +3361,7 @@ async def test_fuzzy_matching(
assert await async_setup_component(hass, "intent", {})
await light_intent.async_setup_intents(hass)
- agent = hass.data[DATA_DEFAULT_ENTITY]
+ agent = async_get_agent(hass)
agent.fuzzy_matching = fuzzy_matching
area_office = area_registry.async_get_or_create("office_id")
diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py
index 244fa6bda7b..8828cc4bd1e 100644
--- a/tests/components/conversation/test_default_agent_intents.py
+++ b/tests/components/conversation/test_default_agent_intents.py
@@ -90,7 +90,7 @@ async def test_cover_set_position(
response = result.response
assert response.response_type == intent.IntentResponseType.ACTION_DONE
- assert response.speech["plain"]["speech"] == "Opened"
+ assert response.speech["plain"]["speech"] == "Opening"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
@@ -104,7 +104,7 @@ async def test_cover_set_position(
response = result.response
assert response.response_type == intent.IntentResponseType.ACTION_DONE
- assert response.speech["plain"]["speech"] == "Closed"
+ assert response.speech["plain"]["speech"] == "Closing"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
@@ -146,7 +146,7 @@ async def test_cover_device_class(
response = result.response
assert response.response_type == intent.IntentResponseType.ACTION_DONE
- assert response.speech["plain"]["speech"] == "Opened the garage"
+ assert response.speech["plain"]["speech"] == "Opening the garage"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
@@ -170,7 +170,7 @@ async def test_valve_intents(
response = result.response
assert response.response_type == intent.IntentResponseType.ACTION_DONE
- assert response.speech["plain"]["speech"] == "Opened"
+ assert response.speech["plain"]["speech"] == "Opening"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
@@ -184,7 +184,7 @@ async def test_valve_intents(
response = result.response
assert response.response_type == intent.IntentResponseType.ACTION_DONE
- assert response.speech["plain"]["speech"] == "Closed"
+ assert response.speech["plain"]["speech"] == "Closing"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
@@ -212,7 +212,14 @@ async def test_vacuum_intents(
await vaccum_intent.async_setup_intents(hass)
entity_id = f"{vacuum.DOMAIN}.rover"
- hass.states.async_set(entity_id, STATE_CLOSED)
+ hass.states.async_set(
+ entity_id,
+ STATE_CLOSED,
+ {
+ ATTR_SUPPORTED_FEATURES: vacuum.VacuumEntityFeature.START
+ | vacuum.VacuumEntityFeature.RETURN_HOME
+ },
+ )
async_expose_entity(hass, conversation.DOMAIN, entity_id, True)
# start
diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py
index 29cd567e904..24fc4d1b135 100644
--- a/tests/components/conversation/test_http.py
+++ b/tests/components/conversation/test_http.py
@@ -7,11 +7,8 @@ from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.conversation import default_agent
-from homeassistant.components.conversation.const import (
- DATA_DEFAULT_ENTITY,
- HOME_ASSISTANT_AGENT,
-)
+from homeassistant.components.conversation import async_get_agent
+from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
@@ -216,8 +213,7 @@ async def test_ws_prepare(
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id
) -> None:
"""Test the Websocket prepare conversation API."""
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
# No intents should be loaded yet
assert not agent._lang_intents.get(hass.config.language)
diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py
index e757c56042b..6c1f7703287 100644
--- a/tests/components/conversation/test_init.py
+++ b/tests/components/conversation/test_init.py
@@ -10,14 +10,12 @@ import voluptuous as vol
from homeassistant.components import conversation
from homeassistant.components.conversation import (
ConversationInput,
+ async_get_agent,
async_handle_intents,
async_handle_sentence_triggers,
default_agent,
)
-from homeassistant.components.conversation.const import (
- DATA_DEFAULT_ENTITY,
- HOME_ASSISTANT_AGENT,
-)
+from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -145,13 +143,13 @@ async def test_custom_agent(
)
-async def test_prepare_reload(hass: HomeAssistant, init_components) -> None:
+@pytest.mark.usefixtures("init_components")
+async def test_prepare_reload(hass: HomeAssistant) -> None:
"""Test calling the reload service."""
language = hass.config.language
+ agent = async_get_agent(hass)
# Load intents
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
await agent.async_prepare(language)
# Confirm intents are loaded
@@ -172,14 +170,12 @@ async def test_prepare_reload(hass: HomeAssistant, init_components) -> None:
assert not agent._lang_intents.get(language)
+@pytest.mark.usefixtures("init_components")
async def test_prepare_fail(hass: HomeAssistant) -> None:
"""Test calling prepare with a non-existent language."""
- assert await async_setup_component(hass, "homeassistant", {})
- assert await async_setup_component(hass, "conversation", {})
+ agent = async_get_agent(hass)
# Load intents
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
await agent.async_prepare("not-a-language")
# Confirm no intents were loaded
@@ -281,6 +277,7 @@ async def test_async_handle_sentence_triggers(
conversation_id=None,
agent_id=conversation.HOME_ASSISTANT_AGENT,
device_id=device_id,
+ satellite_id=None,
language=hass.config.language,
),
)
@@ -318,6 +315,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None:
agent_id=conversation.HOME_ASSISTANT_AGENT,
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
),
)
@@ -335,6 +333,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id=None,
+ satellite_id=None,
language=hass.config.language,
),
)
diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py
index a01f4cd8112..b0af8a59dc2 100644
--- a/tests/components/conversation/test_trigger.py
+++ b/tests/components/conversation/test_trigger.py
@@ -5,8 +5,7 @@ import logging
import pytest
import voluptuous as vol
-from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, default_agent
-from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY
+from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, async_get_agent
from homeassistant.components.conversation.models import ConversationInput
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import trigger
@@ -16,10 +15,8 @@ from tests.typing import WebSocketGenerator
@pytest.fixture(autouse=True)
-async def setup_comp(hass: HomeAssistant) -> None:
+async def setup_comp(hass: HomeAssistant, init_components) -> None:
"""Initialize components."""
- assert await async_setup_component(hass, "homeassistant", {})
- assert await async_setup_component(hass, "conversation", {})
async def test_if_fires_on_event(
@@ -50,6 +47,7 @@ async def test_if_fires_on_event(
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -81,11 +79,13 @@ async def test_if_fires_on_event(
"slots": {},
"details": {},
"device_id": None,
+ "satellite_id": None,
"user_input": {
"agent_id": HOME_ASSISTANT_AGENT,
"context": context.as_dict(),
"conversation_id": None,
"device_id": None,
+ "satellite_id": None,
"language": "en",
"text": "Ha ha ha",
"extra_system_prompt": None,
@@ -185,6 +185,7 @@ async def test_response_same_sentence(
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -230,11 +231,13 @@ async def test_response_same_sentence(
"slots": {},
"details": {},
"device_id": None,
+ "satellite_id": None,
"user_input": {
"agent_id": HOME_ASSISTANT_AGENT,
"context": context.as_dict(),
"conversation_id": None,
"device_id": None,
+ "satellite_id": None,
"language": "en",
"text": "test sentence",
"extra_system_prompt": None,
@@ -376,6 +379,7 @@ async def test_same_trigger_multiple_sentences(
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -408,11 +412,13 @@ async def test_same_trigger_multiple_sentences(
"slots": {},
"details": {},
"device_id": None,
+ "satellite_id": None,
"user_input": {
"agent_id": HOME_ASSISTANT_AGENT,
"context": context.as_dict(),
"conversation_id": None,
"device_id": None,
+ "satellite_id": None,
"language": "en",
"text": "hello",
"extra_system_prompt": None,
@@ -449,6 +455,7 @@ async def test_same_sentence_multiple_triggers(
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -474,6 +481,7 @@ async def test_same_sentence_multiple_triggers(
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -590,6 +598,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
"slots": "{{ trigger.slots }}",
"details": "{{ trigger.details }}",
"device_id": "{{ trigger.device_id }}",
+ "satellite_id": "{{ trigger.satellite_id }}",
"user_input": "{{ trigger.user_input }}",
}
},
@@ -636,11 +645,13 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
},
},
"device_id": None,
+ "satellite_id": None,
"user_input": {
"agent_id": HOME_ASSISTANT_AGENT,
"context": context.as_dict(),
"conversation_id": None,
"device_id": None,
+ "satellite_id": None,
"language": "en",
"text": "play the white album by the beatles",
"extra_system_prompt": None,
@@ -660,14 +671,13 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None:
"command": ["test sentence"],
},
"action": {
- "set_conversation_response": "{{ trigger.device_id }}",
+ "set_conversation_response": "{{ trigger.device_id }} - {{ trigger.satellite_id }}",
},
}
},
)
- agent = hass.data[DATA_DEFAULT_ENTITY]
- assert isinstance(agent, default_agent.DefaultAgent)
+ agent = async_get_agent(hass)
result = await agent.async_process(
ConversationInput(
@@ -675,8 +685,12 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None:
context=Context(),
conversation_id=None,
device_id="my_device",
+ satellite_id="assist_satellite.my_satellite",
language=hass.config.language,
agent_id=None,
)
)
- assert result.response.speech["plain"]["speech"] == "my_device"
+ assert (
+ result.response.speech["plain"]["speech"]
+ == "my_device - assist_satellite.my_satellite"
+ )
diff --git a/tests/components/cync/__init__.py b/tests/components/cync/__init__.py
new file mode 100644
index 00000000000..56cab084f99
--- /dev/null
+++ b/tests/components/cync/__init__.py
@@ -0,0 +1,15 @@
+"""Tests for the Cync integration."""
+
+from __future__ import annotations
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+ """Sets up the Cync integration to be used in testing."""
+ config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
diff --git a/tests/components/cync/conftest.py b/tests/components/cync/conftest.py
new file mode 100644
index 00000000000..2ea6e352a75
--- /dev/null
+++ b/tests/components/cync/conftest.py
@@ -0,0 +1,91 @@
+"""Common fixtures for the Cync tests."""
+
+from collections.abc import Generator
+import time
+from unittest.mock import AsyncMock, patch
+
+from pycync import Cync, CyncHome
+import pytest
+
+from homeassistant.components.cync.const import (
+ CONF_AUTHORIZE_STRING,
+ CONF_EXPIRES_AT,
+ CONF_REFRESH_TOKEN,
+ CONF_USER_ID,
+ DOMAIN,
+)
+from homeassistant.const import CONF_ACCESS_TOKEN
+
+from .const import MOCKED_EMAIL, MOCKED_USER
+
+from tests.common import MockConfigEntry, load_json_object_fixture
+
+
+@pytest.fixture(autouse=True)
+def auth_client():
+ """Mock a pycync.Auth client."""
+ with patch(
+ "homeassistant.components.cync.config_flow.Auth", autospec=True
+ ) as sc_class_mock:
+ client_mock = sc_class_mock.return_value
+ client_mock.user = MOCKED_USER
+ client_mock.username = MOCKED_EMAIL
+ yield client_mock
+
+
+@pytest.fixture(autouse=True)
+def cync_client():
+ """Mock a pycync.Cync client."""
+ with (
+ patch(
+ "homeassistant.components.cync.coordinator.Cync",
+ spec=Cync,
+ ) as cync_mock,
+ patch(
+ "homeassistant.components.cync.Cync",
+ new=cync_mock,
+ ),
+ ):
+ cync_mock.get_logged_in_user.return_value = MOCKED_USER
+
+ home_fixture: CyncHome = CyncHome.from_dict(
+ load_json_object_fixture("home.json", DOMAIN)
+ )
+ cync_mock.get_homes.return_value = [home_fixture]
+
+ available_mock_devices = [
+ device
+ for device in home_fixture.get_flattened_device_list()
+ if device.is_online
+ ]
+ cync_mock.get_devices.return_value = available_mock_devices
+
+ cync_mock.create.return_value = cync_mock
+ client_mock = cync_mock.return_value
+ yield client_mock
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override async_setup_entry."""
+ with patch(
+ "homeassistant.components.cync.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+ """Mock a Cync config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ title=MOCKED_EMAIL,
+ unique_id=str(MOCKED_USER.user_id),
+ data={
+ CONF_USER_ID: MOCKED_USER.user_id,
+ CONF_AUTHORIZE_STRING: "test_authorize_string",
+ CONF_EXPIRES_AT: (time.time() * 1000) + 3600000,
+ CONF_ACCESS_TOKEN: "test_token",
+ CONF_REFRESH_TOKEN: "test_refresh_token",
+ },
+ )
diff --git a/tests/components/cync/const.py b/tests/components/cync/const.py
new file mode 100644
index 00000000000..79f7e8b8b21
--- /dev/null
+++ b/tests/components/cync/const.py
@@ -0,0 +1,14 @@
+"""Test constants used in Cync tests."""
+
+import time
+
+import pycync
+
+MOCKED_USER = pycync.User(
+ "test_token",
+ "test_refresh_token",
+ "test_authorize_string",
+ 123456789,
+ expires_at=(time.time() * 1000) + 3600000,
+)
+MOCKED_EMAIL = "test@testuser.com"
diff --git a/tests/components/cync/fixtures/home.json b/tests/components/cync/fixtures/home.json
new file mode 100644
index 00000000000..22e009de965
--- /dev/null
+++ b/tests/components/cync/fixtures/home.json
@@ -0,0 +1,76 @@
+{
+ "name": "My Home",
+ "home_id": 1000,
+ "rooms": [
+ {
+ "name": "Bedroom",
+ "room_id": 1100,
+ "home_id": 1000,
+ "groups": [],
+ "devices": [
+ {
+ "name": "Bedroom Lamp",
+ "is_online": true,
+ "wifi_connected": true,
+ "device_id": 1101,
+ "mesh_device_id": 10001,
+ "home_id": 1000,
+ "device_type_id": 137,
+ "device_type": "LIGHT",
+ "mac_address": "ABCDEF123456",
+ "product_id": "product123",
+ "authorize_code": "abcd_code",
+ "is_on": true,
+ "brightness": 80,
+ "color_temp": 20
+ }
+ ]
+ },
+ {
+ "name": "Office",
+ "room_id": 1200,
+ "home_id": 1000,
+ "groups": [
+ {
+ "name": "Office Lamp",
+ "group_id": 1110,
+ "home_id": 1000,
+ "devices": [
+ {
+ "name": "Lamp Bulb 1",
+ "is_online": true,
+ "wifi_connected": false,
+ "device_id": 1111,
+ "mesh_device_id": 10002,
+ "home_id": 1000,
+ "device_type_id": 137,
+ "device_type": "LIGHT",
+ "mac_address": "654321ABCDEF",
+ "product_id": "product123",
+ "authorize_code": "abcd_code",
+ "is_on": true,
+ "brightness": 90,
+ "color_temp": 254,
+ "rgb": [120, 145, 180]
+ },
+ {
+ "name": "Lamp Bulb 2",
+ "is_online": false,
+ "wifi_connected": false,
+ "device_id": 1112,
+ "mesh_device_id": 10003,
+ "home_id": 1000,
+ "device_type_id": 137,
+ "device_type": "LIGHT",
+ "mac_address": "FEDCBA654321",
+ "product_id": "product123",
+ "authorize_code": "abcd_code"
+ }
+ ]
+ }
+ ],
+ "devices": []
+ }
+ ],
+ "global_devices": []
+}
diff --git a/tests/components/cync/snapshots/test_light.ambr b/tests/components/cync/snapshots/test_light.ambr
new file mode 100644
index 00000000000..fbe56bb1c75
--- /dev/null
+++ b/tests/components/cync/snapshots/test_light.ambr
@@ -0,0 +1,233 @@
+# serializer version: 1
+# name: test_entities[light.bedroom_lamp-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'light',
+ 'entity_category': None,
+ 'entity_id': 'light.bedroom_lamp',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'cync',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'light',
+ 'unique_id': '1000-1101',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entities[light.bedroom_lamp-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'brightness': 205,
+ 'color_mode': ,
+ 'color_temp': 333,
+ 'color_temp_kelvin': 2999,
+ 'friendly_name': 'Bedroom Lamp',
+ 'hs_color': tuple(
+ 27.827,
+ 56.922,
+ ),
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'rgb_color': tuple(
+ 255,
+ 177,
+ 110,
+ ),
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ 'supported_features': ,
+ 'xy_color': tuple(
+ 0.496,
+ 0.383,
+ ),
+ }),
+ 'context': ,
+ 'entity_id': 'light.bedroom_lamp',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entities[light.lamp_bulb_1-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'light',
+ 'entity_category': None,
+ 'entity_id': 'light.lamp_bulb_1',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'cync',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'light',
+ 'unique_id': '1000-1111',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entities[light.lamp_bulb_1-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'brightness': 230,
+ 'color_mode': ,
+ 'color_temp': None,
+ 'color_temp_kelvin': None,
+ 'friendly_name': 'Lamp Bulb 1',
+ 'hs_color': tuple(
+ 215.0,
+ 33.333,
+ ),
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'rgb_color': tuple(
+ 120,
+ 145,
+ 180,
+ ),
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ 'supported_features': ,
+ 'xy_color': tuple(
+ 0.248,
+ 0.27,
+ ),
+ }),
+ 'context': ,
+ 'entity_id': 'light.lamp_bulb_1',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entities[light.lamp_bulb_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'light',
+ 'entity_category': None,
+ 'entity_id': 'light.lamp_bulb_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'cync',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'light',
+ 'unique_id': '1000-1112',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entities[light.lamp_bulb_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Lamp Bulb 2',
+ 'max_color_temp_kelvin': 7000,
+ 'max_mireds': 500,
+ 'min_color_temp_kelvin': 2000,
+ 'min_mireds': 142,
+ 'supported_color_modes': list([
+ ,
+ ,
+ ]),
+ 'supported_features': ,
+ }),
+ 'context': ,
+ 'entity_id': 'light.lamp_bulb_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'unavailable',
+ })
+# ---
diff --git a/tests/components/cync/test_config_flow.py b/tests/components/cync/test_config_flow.py
new file mode 100644
index 00000000000..28f0aee09da
--- /dev/null
+++ b/tests/components/cync/test_config_flow.py
@@ -0,0 +1,260 @@
+"""Test the Cync config flow."""
+
+from unittest.mock import ANY, AsyncMock, MagicMock
+
+from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
+import pytest
+
+from homeassistant.components.cync.const import (
+ CONF_AUTHORIZE_STRING,
+ CONF_EXPIRES_AT,
+ CONF_REFRESH_TOKEN,
+ CONF_TWO_FACTOR_CODE,
+ CONF_USER_ID,
+ DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .const import MOCKED_EMAIL, MOCKED_USER
+
+from tests.common import MockConfigEntry
+
+
+async def test_form_auth_success(
+ hass: HomeAssistant, mock_setup_entry: AsyncMock
+) -> None:
+ """Test that an auth flow without two factor succeeds."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == MOCKED_EMAIL
+ assert result["data"] == {
+ CONF_USER_ID: MOCKED_USER.user_id,
+ CONF_AUTHORIZE_STRING: "test_authorize_string",
+ CONF_EXPIRES_AT: ANY,
+ CONF_ACCESS_TOKEN: "test_token",
+ CONF_REFRESH_TOKEN: "test_refresh_token",
+ }
+ assert result["result"].unique_id == str(MOCKED_USER.user_id)
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_two_factor_success(
+ hass: HomeAssistant, mock_setup_entry: AsyncMock, auth_client: MagicMock
+) -> None:
+ """Test we handle a request for a two factor code."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ auth_client.login.side_effect = TwoFactorRequiredError
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+ assert result["step_id"] == "two_factor"
+
+ # Enter two factor code
+ auth_client.login.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_TWO_FACTOR_CODE: "123456",
+ },
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == MOCKED_EMAIL
+ assert result["data"] == {
+ CONF_USER_ID: MOCKED_USER.user_id,
+ CONF_AUTHORIZE_STRING: "test_authorize_string",
+ CONF_EXPIRES_AT: ANY,
+ CONF_ACCESS_TOKEN: "test_token",
+ CONF_REFRESH_TOKEN: "test_refresh_token",
+ }
+ assert result["result"].unique_id == str(MOCKED_USER.user_id)
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_unique_id_already_exists(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test that setting up a config with a unique ID that already exists fails."""
+ mock_config_entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
+
+
+@pytest.mark.parametrize(
+ ("error_type", "error_string"),
+ [
+ (AuthFailedError, "invalid_auth"),
+ (CyncError, "cannot_connect"),
+ (Exception, "unknown"),
+ ],
+)
+async def test_form_two_factor_errors(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ auth_client: MagicMock,
+ error_type: Exception,
+ error_string: str,
+) -> None:
+ """Test we handle a request for a two factor code with errors."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ auth_client.login.side_effect = TwoFactorRequiredError
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+ assert result["step_id"] == "two_factor"
+
+ # Enter two factor code
+ auth_client.login.side_effect = error_type
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_TWO_FACTOR_CODE: "123456",
+ },
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": error_string}
+ assert result["step_id"] == "user"
+
+ # Make sure the config flow tests finish with either an
+ # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so
+ # we can show the config flow is able to recover from an error.
+ auth_client.login.side_effect = TwoFactorRequiredError
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ # Enter two factor code
+ auth_client.login.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_TWO_FACTOR_CODE: "567890",
+ },
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == MOCKED_EMAIL
+ assert result["data"] == {
+ CONF_USER_ID: MOCKED_USER.user_id,
+ CONF_AUTHORIZE_STRING: "test_authorize_string",
+ CONF_EXPIRES_AT: ANY,
+ CONF_ACCESS_TOKEN: "test_token",
+ CONF_REFRESH_TOKEN: "test_refresh_token",
+ }
+ assert result["result"].unique_id == str(MOCKED_USER.user_id)
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ ("error_type", "error_string"),
+ [
+ (AuthFailedError, "invalid_auth"),
+ (CyncError, "cannot_connect"),
+ (Exception, "unknown"),
+ ],
+)
+async def test_form_errors(
+ hass: HomeAssistant,
+ mock_setup_entry: AsyncMock,
+ auth_client: MagicMock,
+ error_type: Exception,
+ error_string: str,
+) -> None:
+ """Test we handle errors in the user step of the setup."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ auth_client.login.side_effect = error_type
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": error_string}
+ assert result["step_id"] == "user"
+
+ # Make sure the config flow tests finish with either an
+ # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so
+ # we can show the config flow is able to recover from an error.
+ auth_client.login.side_effect = None
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: MOCKED_EMAIL,
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == MOCKED_EMAIL
+ assert result["data"] == {
+ CONF_USER_ID: MOCKED_USER.user_id,
+ CONF_AUTHORIZE_STRING: "test_authorize_string",
+ CONF_EXPIRES_AT: ANY,
+ CONF_ACCESS_TOKEN: "test_token",
+ CONF_REFRESH_TOKEN: "test_refresh_token",
+ }
+ assert result["result"].unique_id == str(MOCKED_USER.user_id)
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/cync/test_light.py b/tests/components/cync/test_light.py
new file mode 100644
index 00000000000..b5563949f45
--- /dev/null
+++ b/tests/components/cync/test_light.py
@@ -0,0 +1,23 @@
+"""Tests for the Cync integration light platform."""
+
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+async def test_entities(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test that light attributes are properly set on setup."""
+
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py
index 2380d5ad798..54caa79539b 100644
--- a/tests/components/daikin/test_init.py
+++ b/tests/components/daikin/test_init.py
@@ -187,7 +187,7 @@ async def test_client_update_connection_error(
type(mock_daikin).update_status.side_effect = ClientConnectionError
- freezer.tick(timedelta(seconds=60))
+ freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr
index 59e77c4fb12..884ce49edb6 100644
--- a/tests/components/deconz/snapshots/test_hub.ambr
+++ b/tests/components/deconz/snapshots/test_hub.ambr
@@ -19,7 +19,7 @@
}),
'labels': set({
}),
- 'manufacturer': 'Dresden Elektronik',
+ 'manufacturer': 'dresden elektronik',
'model': 'deCONZ',
'model_id': None,
'name': 'deCONZ mock gateway',
diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py
index 438fe8c17f5..49f9517fe05 100644
--- a/tests/components/deconz/test_deconz_event.py
+++ b/tests/components/deconz/test_deconz_event.py
@@ -76,14 +76,14 @@ async def test_deconz_events(
) -> None:
"""Test successful creation of deconz events."""
assert len(hass.states.async_all()) == 3
- # 5 switches + 2 additional devices for deconz service and host
+ # 5 switches + 1 additional device for deconz gateway
assert (
len(
dr.async_entries_for_config_entry(
device_registry, config_entry_setup.entry_id
)
)
- == 7
+ == 6
)
assert hass.states.get("sensor.switch_2_battery").state == "100"
assert hass.states.get("sensor.switch_3_battery").state == "100"
@@ -233,14 +233,14 @@ async def test_deconz_alarm_events(
) -> None:
"""Test successful creation of deconz alarm events."""
assert len(hass.states.async_all()) == 4
- # 1 alarm control device + 2 additional devices for deconz service and host
+ # 1 alarm control device + 1 additional device for deconz gateway
assert (
len(
dr.async_entries_for_config_entry(
device_registry, config_entry_setup.entry_id
)
)
- == 3
+ == 2
)
captured_events = async_capture_events(hass, CONF_DECONZ_ALARM_EVENT)
@@ -362,7 +362,7 @@ async def test_deconz_presence_events(
device_registry, config_entry_setup.entry_id
)
)
- == 3
+ == 2
)
device = device_registry.async_get_device(
@@ -439,7 +439,7 @@ async def test_deconz_relative_rotary_events(
device_registry, config_entry_setup.entry_id
)
)
- == 3
+ == 2
)
device = device_registry.async_get_device(
@@ -508,5 +508,5 @@ async def test_deconz_events_bad_unique_id(
device_registry, config_entry_setup.entry_id
)
)
- == 2
+ == 1
)
diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py
index 558eb628705..32a6510db08 100644
--- a/tests/components/deconz/test_services.py
+++ b/tests/components/deconz/test_services.py
@@ -56,7 +56,7 @@ async def test_configure_service_with_field(
{
"name": "Test",
"state": {"reachable": True},
- "type": "Light",
+ "type": "Dimmable light",
"uniqueid": "00:00:00:00:00:00:00:01-00",
}
],
@@ -85,7 +85,7 @@ async def test_configure_service_with_entity(
{
"name": "Test",
"state": {"reachable": True},
- "type": "Light",
+ "type": "Dimmable light",
"uniqueid": "00:00:00:00:00:00:00:01-00",
}
],
@@ -204,7 +204,7 @@ async def test_service_refresh_devices(
"1": {
"name": "Light 1 name",
"state": {"reachable": True},
- "type": "Light",
+ "type": "Dimmable light",
"uniqueid": "00:00:00:00:00:00:00:01-00",
}
},
@@ -270,7 +270,7 @@ async def test_service_refresh_devices_trigger_no_state_update(
"1": {
"name": "Light 1 name",
"state": {"reachable": True},
- "type": "Light",
+ "type": "Dimmable light",
"uniqueid": "00:00:00:00:00:00:00:01-00",
}
},
@@ -301,7 +301,7 @@ async def test_service_refresh_devices_trigger_no_state_update(
{
"name": "Light 0 name",
"state": {"reachable": True},
- "type": "Light",
+ "type": "Dimmable light",
"uniqueid": "00:00:00:00:00:00:00:01-00",
}
],
@@ -327,7 +327,12 @@ async def test_remove_orphaned_entries_service(
"""Test service works and also don't remove more than expected."""
device = device_registry.async_get_or_create(
config_entry_id=config_entry_setup.entry_id,
- connections={(dr.CONNECTION_NETWORK_MAC, "123")},
+ identifiers={(DOMAIN, BRIDGE_ID)},
+ )
+
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry_setup.entry_id,
+ identifiers={(DOMAIN, "orphaned")},
)
assert (
@@ -338,7 +343,7 @@ async def test_remove_orphaned_entries_service(
if config_entry_setup.entry_id in entry.config_entries
]
)
- == 5 # Host, gateway, light, switch and orphan
+ == 4 # Gateway, light, switch and orphan
)
entity_registry.async_get_or_create(
@@ -374,7 +379,7 @@ async def test_remove_orphaned_entries_service(
if config_entry_setup.entry_id in entry.config_entries
]
)
- == 4 # Host, gateway, light and switch
+ == 3 # Gateway, light and switch
)
assert (
diff --git a/tests/components/deluge/__init__.py b/tests/components/deluge/__init__.py
index c9027f0c11f..5d5e6bf3e02 100644
--- a/tests/components/deluge/__init__.py
+++ b/tests/components/deluge/__init__.py
@@ -21,3 +21,9 @@ GET_TORRENT_STATUS_RESPONSE = {
"dht_upload_rate": 7818.0,
"dht_download_rate": 2658.0,
}
+
+GET_TORRENT_STATES_RESPONSE = {
+ "6dcd3f46d09547b62bf07ba9b2943c95d53ddae3": {b"state": b"Seeding"},
+ "1c56ea49918b9baed94cf4bc0ee9f324efc8841a": {b"state": b"Downloading"},
+ "fbf4dab701189a344fa5ab06d7b87c11a74e3da0": {b"state": b"Seeding"},
+}
diff --git a/tests/components/deluge/test_coordinator.py b/tests/components/deluge/test_coordinator.py
new file mode 100644
index 00000000000..a2ca30d7c94
--- /dev/null
+++ b/tests/components/deluge/test_coordinator.py
@@ -0,0 +1,15 @@
+"""Test Deluge coordinator.py methods."""
+
+from homeassistant.components.deluge.const import DelugeSensorType
+from homeassistant.components.deluge.coordinator import count_states
+
+from . import GET_TORRENT_STATES_RESPONSE
+
+
+def test_get_count() -> None:
+ """Tests count_states()."""
+
+ states = count_states(GET_TORRENT_STATES_RESPONSE)
+
+ assert states[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value] == 1
+ assert states[DelugeSensorType.SEEDING_COUNT_SENSOR.value] == 2
diff --git a/tests/components/derivative/conftest.py b/tests/components/derivative/conftest.py
new file mode 100644
index 00000000000..223787d842d
--- /dev/null
+++ b/tests/components/derivative/conftest.py
@@ -0,0 +1,74 @@
+"""Fixtures for derivative tests."""
+
+import pytest
+
+from homeassistant.components.derivative.config_flow import ConfigFlowHandler
+from homeassistant.components.derivative.const import DOMAIN
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry:
+ """Fixture to create a sensor config entry."""
+ sensor_config_entry = MockConfigEntry()
+ sensor_config_entry.add_to_hass(hass)
+ return sensor_config_entry
+
+
+@pytest.fixture
+def sensor_device(
+ device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry
+) -> dr.DeviceEntry:
+ """Fixture to create a sensor device."""
+ return device_registry.async_get_or_create(
+ config_entry_id=sensor_config_entry.entry_id,
+ connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+
+
+@pytest.fixture
+def sensor_entity_entry(
+ entity_registry: er.EntityRegistry,
+ sensor_config_entry: ConfigEntry,
+ sensor_device: dr.DeviceEntry,
+) -> er.RegistryEntry:
+ """Fixture to create a sensor entity entry."""
+ return entity_registry.async_get_or_create(
+ "sensor",
+ "test",
+ "unique",
+ config_entry=sensor_config_entry,
+ device_id=sensor_device.id,
+ original_name="ABC",
+ )
+
+
+@pytest.fixture
+def derivative_config_entry(
+ hass: HomeAssistant,
+ sensor_entity_entry: er.RegistryEntry,
+) -> MockConfigEntry:
+ """Fixture to create a derivative config entry."""
+ config_entry = MockConfigEntry(
+ data={},
+ domain=DOMAIN,
+ options={
+ "name": "My derivative",
+ "round": 1.0,
+ "source": sensor_entity_entry.entity_id,
+ "time_window": {"seconds": 0.0},
+ "unit_prefix": "k",
+ "unit_time": "min",
+ },
+ title="My derivative",
+ version=ConfigFlowHandler.VERSION,
+ minor_version=ConfigFlowHandler.MINOR_VERSION,
+ )
+
+ config_entry.add_to_hass(hass)
+
+ return config_entry
diff --git a/tests/components/derivative/test_diagnostics.py b/tests/components/derivative/test_diagnostics.py
new file mode 100644
index 00000000000..98ceaba1c55
--- /dev/null
+++ b/tests/components/derivative/test_diagnostics.py
@@ -0,0 +1,24 @@
+"""Tests for derivative diagnostics."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_diagnostics(
+ hass: HomeAssistant, hass_client: ClientSessionGenerator, derivative_config_entry
+) -> None:
+ """Test diagnostics for config entry."""
+
+ assert await hass.config_entries.async_setup(derivative_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await get_diagnostics_for_config_entry(
+ hass, hass_client, derivative_config_entry
+ )
+
+ assert isinstance(result, dict)
+ assert result["config_entry"]["domain"] == "derivative"
+ assert result["config_entry"]["options"]["name"] == "My derivative"
+ assert result["entity"][0]["entity_id"] == "sensor.my_derivative"
diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py
index 005e6ec91d9..f2f505bd2e7 100644
--- a/tests/components/derivative/test_init.py
+++ b/tests/components/derivative/test_init.py
@@ -5,7 +5,6 @@ from unittest.mock import patch
import pytest
from homeassistant.components import derivative
-from homeassistant.components.derivative.config_flow import ConfigFlowHandler
from homeassistant.components.derivative.const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import Event, HomeAssistant, callback
@@ -15,69 +14,6 @@ from homeassistant.helpers.event import async_track_entity_registry_updated_even
from tests.common import MockConfigEntry
-@pytest.fixture
-def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry:
- """Fixture to create a sensor config entry."""
- sensor_config_entry = MockConfigEntry()
- sensor_config_entry.add_to_hass(hass)
- return sensor_config_entry
-
-
-@pytest.fixture
-def sensor_device(
- device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry
-) -> dr.DeviceEntry:
- """Fixture to create a sensor device."""
- return device_registry.async_get_or_create(
- config_entry_id=sensor_config_entry.entry_id,
- connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
- )
-
-
-@pytest.fixture
-def sensor_entity_entry(
- entity_registry: er.EntityRegistry,
- sensor_config_entry: ConfigEntry,
- sensor_device: dr.DeviceEntry,
-) -> er.RegistryEntry:
- """Fixture to create a sensor entity entry."""
- return entity_registry.async_get_or_create(
- "sensor",
- "test",
- "unique",
- config_entry=sensor_config_entry,
- device_id=sensor_device.id,
- original_name="ABC",
- )
-
-
-@pytest.fixture
-def derivative_config_entry(
- hass: HomeAssistant,
- sensor_entity_entry: er.RegistryEntry,
-) -> MockConfigEntry:
- """Fixture to create a derivative config entry."""
- config_entry = MockConfigEntry(
- data={},
- domain=DOMAIN,
- options={
- "name": "My derivative",
- "round": 1.0,
- "source": sensor_entity_entry.entity_id,
- "time_window": {"seconds": 0.0},
- "unit_prefix": "k",
- "unit_time": "min",
- },
- title="My derivative",
- version=ConfigFlowHandler.VERSION,
- minor_version=ConfigFlowHandler.MINOR_VERSION,
- )
-
- config_entry.add_to_hass(hass)
-
- return config_entry
-
-
def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Track entity registry actions for an entity."""
events = []
diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py
index 211e6f673ca..5a601ad26dd 100644
--- a/tests/components/derivative/test_sensor.py
+++ b/tests/components/derivative/test_sensor.py
@@ -717,7 +717,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None:
expected_times = [0, 20, 30, 35, 50, 60]
expected_values = ["0.00", "0.50", "2.00", "2.00", "1.00", "3.00"]
- config, entity_id = await _setup_sensor(hass, {"unit_time": UnitOfTime.SECONDS})
+ _config, entity_id = await _setup_sensor(hass, {"unit_time": UnitOfTime.SECONDS})
base_time = dt_util.utcnow()
actual_times = []
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
index 456202a63a4..c04dd242e61 100644
--- a/tests/components/device_automation/test_init.py
+++ b/tests/components/device_automation/test_init.py
@@ -1,6 +1,6 @@
"""The test for light device automation."""
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
import attr
import pytest
@@ -1088,7 +1088,7 @@ async def test_automation_with_dynamically_validated_condition(
module_cache = hass.data[loader.DATA_COMPONENTS]
module = module_cache["fake_integration.device_condition"]
- module.async_validate_condition_config = AsyncMock()
+ module.async_validate_condition_config = AsyncMock(return_value=MagicMock())
config_entry = MockConfigEntry(domain="fake_integration", data={})
config_entry.mock_state(hass, ConfigEntryState.LOADED)
diff --git a/tests/components/devolo_home_control/conftest.py b/tests/components/devolo_home_control/conftest.py
index 55e072d075c..33655c8cf83 100644
--- a/tests/components/devolo_home_control/conftest.py
+++ b/tests/components/devolo_home_control/conftest.py
@@ -1,41 +1,25 @@
"""Fixtures for tests."""
from collections.abc import Generator
+from itertools import cycle
from unittest.mock import MagicMock, patch
import pytest
-@pytest.fixture
-def credentials_valid() -> bool:
- """Mark test as credentials invalid."""
- return True
-
-
-@pytest.fixture
-def maintenance() -> bool:
- """Mark test as maintenance mode on."""
- return False
-
-
@pytest.fixture(autouse=True)
-def patch_mydevolo(credentials_valid: bool, maintenance: bool) -> Generator[None]:
+def mydevolo() -> Generator[None]:
"""Fixture to patch mydevolo into a desired state."""
- with (
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.credentials_valid",
- return_value=credentials_valid,
- ),
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.maintenance",
- return_value=maintenance,
- ),
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.get_gateway_ids",
- return_value=["1400000000000001", "1400000000000002"],
- ),
+ mydevolo = MagicMock()
+ mydevolo.uuid.return_value = "123456"
+ mydevolo.credentials_valid.return_value = True
+ mydevolo.maintenance.return_value = False
+ mydevolo.get_gateway_ids.return_value = ["1400000000000001", "1400000000000002"]
+ with patch(
+ "homeassistant.components.devolo_home_control.Mydevolo",
+ side_effect=cycle([mydevolo]),
):
- yield
+ yield mydevolo
@pytest.fixture(autouse=True)
diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py
index 9367d746d2e..c872bc5c65b 100644
--- a/tests/components/devolo_home_control/test_config_flow.py
+++ b/tests/components/devolo_home_control/test_config_flow.py
@@ -1,13 +1,12 @@
"""Test the devolo_home_control config flow."""
-from unittest.mock import patch
-
-import pytest
+from unittest.mock import MagicMock
from homeassistant import config_entries
from homeassistant.components.devolo_home_control.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowResult, FlowResultType
+from homeassistant.data_entry_flow import FlowResultType
from .const import (
DISCOVERY_INFO,
@@ -20,21 +19,6 @@ from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
- )
- assert result["step_id"] == "user"
- assert result["type"] is FlowResultType.FORM
- assert result["errors"] == {}
-
- await _setup(hass, result)
-
-
-@pytest.mark.parametrize("credentials_valid", [False])
-async def test_form_invalid_credentials_user(hass: HomeAssistant) -> None:
- """Test if we get the error message on invalid credentials."""
-
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -44,26 +28,54 @@ async def test_form_invalid_credentials_user(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- {"username": "test-username", "password": "test-password"},
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "devolo Home Control"
+ assert result["data"] == {
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ }
+
+
+async def test_form_invalid_credentials_user(
+ hass: HomeAssistant, mydevolo: MagicMock
+) -> None:
+ """Test if we get the error message on invalid credentials."""
+ mydevolo.credentials_valid.return_value = False
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "wrong-password"},
+ )
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
+ mydevolo.credentials_valid.return_value = True
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "correct-password"},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == {
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "correct-password",
+ }
+
async def test_form_already_configured(hass: HomeAssistant) -> None:
"""Test if we get the error message on already configured."""
- with patch(
- "homeassistant.components.devolo_home_control.Mydevolo.uuid",
- return_value="123456",
- ):
- MockConfigEntry(domain=DOMAIN, unique_id="123456", data={}).add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_USER},
- data={"username": "test-username", "password": "test-password"},
- )
- assert result["type"] is FlowResultType.ABORT
- assert result["reason"] == "already_configured"
+ MockConfigEntry(domain=DOMAIN, unique_id="123456", data={}).add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "already_configured"
async def test_form_zeroconf(hass: HomeAssistant) -> None:
@@ -73,33 +85,46 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_ZEROCONF},
data=DISCOVERY_INFO,
)
-
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] is FlowResultType.FORM
- await _setup(hass, result)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == "devolo Home Control"
+ assert result["data"] == {
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ }
-@pytest.mark.parametrize("credentials_valid", [False])
-async def test_form_invalid_credentials_zeroconf(hass: HomeAssistant) -> None:
+async def test_form_invalid_credentials_zeroconf(
+ hass: HomeAssistant, mydevolo: MagicMock
+) -> None:
"""Test if we get the error message on invalid credentials."""
-
+ mydevolo.credentials_valid.return_value = False
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=DISCOVERY_INFO,
)
- assert result["step_id"] == "zeroconf_confirm"
- assert result["type"] is FlowResultType.FORM
-
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- {"username": "test-username", "password": "test-password"},
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
)
-
+ assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
+ mydevolo.credentials_valid.return_value = True
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "correct-password"},
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+
async def test_zeroconf_wrong_device(hass: HomeAssistant) -> None:
"""Test that the zeroconf ignores wrong devices."""
@@ -108,7 +133,6 @@ async def test_zeroconf_wrong_device(hass: HomeAssistant) -> None:
context={"source": config_entries.SOURCE_ZEROCONF},
data=DISCOVERY_INFO_WRONG_DEVOLO_DEVICE,
)
-
assert result["reason"] == "Not a devolo Home Control gateway."
assert result["type"] is FlowResultType.ABORT
@@ -128,8 +152,8 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
domain=DOMAIN,
unique_id="123456",
data={
- "username": "test-username",
- "password": "test-password",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
},
)
mock_config.add_to_hass(hass)
@@ -137,35 +161,25 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
assert result["step_id"] == "reauth_confirm"
assert result["type"] is FlowResultType.FORM
- with (
- patch(
- "homeassistant.components.devolo_home_control.async_setup_entry",
- return_value=True,
- ) as mock_setup_entry,
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.uuid",
- return_value="123456",
- ),
- ):
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- {"username": "test-username-new", "password": "test-password-new"},
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.ABORT
- assert len(mock_setup_entry.mock_calls) == 1
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username-new", CONF_PASSWORD: "test-password-new"},
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
-@pytest.mark.parametrize("credentials_valid", [False])
-async def test_form_invalid_credentials_reauth(hass: HomeAssistant) -> None:
+async def test_form_invalid_credentials_reauth(
+ hass: HomeAssistant, mydevolo: MagicMock
+) -> None:
"""Test if we get the error message on invalid credentials."""
+ mydevolo.credentials_valid.return_value = False
mock_config = MockConfigEntry(
domain=DOMAIN,
unique_id="123456",
data={
- "username": "test-username",
- "password": "test-password",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
},
)
mock_config.add_to_hass(hass)
@@ -173,71 +187,38 @@ async def test_form_invalid_credentials_reauth(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
- {"username": "test-username", "password": "test-password"},
+ {CONF_USERNAME: "test-username", CONF_PASSWORD: "wrong-password"},
)
-
assert result["errors"] == {"base": "invalid_auth"}
+ assert result["type"] is FlowResultType.FORM
+
+ mydevolo.credentials_valid.return_value = True
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username-new", CONF_PASSWORD: "correct-password"},
+ )
+ assert result["reason"] == "reauth_successful"
+ assert result["type"] is FlowResultType.ABORT
async def test_form_uuid_change_reauth(hass: HomeAssistant) -> None:
"""Test that the reauth confirmation form is served."""
mock_config = MockConfigEntry(
domain=DOMAIN,
- unique_id="123456",
+ unique_id="123457",
data={
- "username": "test-username",
- "password": "test-password",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
},
)
mock_config.add_to_hass(hass)
result = await mock_config.start_reauth_flow(hass)
-
assert result["step_id"] == "reauth_confirm"
assert result["type"] is FlowResultType.FORM
- with (
- patch(
- "homeassistant.components.devolo_home_control.async_setup_entry",
- return_value=True,
- ),
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.uuid",
- return_value="789123",
- ),
- ):
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- {"username": "test-username-new", "password": "test-password-new"},
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.FORM
- assert result2["errors"] == {"base": "reauth_failed"}
-
-
-async def _setup(hass: HomeAssistant, result: FlowResult) -> None:
- """Finish configuration steps."""
- with (
- patch(
- "homeassistant.components.devolo_home_control.async_setup_entry",
- return_value=True,
- ) as mock_setup_entry,
- patch(
- "homeassistant.components.devolo_home_control.Mydevolo.uuid",
- return_value="123456",
- ),
- ):
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- {"username": "test-username", "password": "test-password"},
- )
- await hass.async_block_till_done()
-
- assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert result2["title"] == "devolo Home Control"
- assert result2["data"] == {
- "username": "test-username",
- "password": "test-password",
- }
-
- assert len(mock_setup_entry.mock_calls) == 1
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "test-username-new", CONF_PASSWORD: "test-password-new"},
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {"base": "reauth_failed"}
diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py
index fb97447264d..c9b39366cdd 100644
--- a/tests/components/devolo_home_control/test_init.py
+++ b/tests/components/devolo_home_control/test_init.py
@@ -1,9 +1,8 @@
"""Tests for the devolo Home Control integration."""
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
from devolo_home_control_api.exceptions.gateway import GatewayOfflineError
-import pytest
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.devolo_home_control.const import DOMAIN
@@ -27,17 +26,21 @@ async def test_setup_entry(hass: HomeAssistant) -> None:
assert entry.state is ConfigEntryState.LOADED
-@pytest.mark.parametrize("credentials_valid", [False])
-async def test_setup_entry_credentials_invalid(hass: HomeAssistant) -> None:
+async def test_setup_entry_credentials_invalid(
+ hass: HomeAssistant, mydevolo: MagicMock
+) -> None:
"""Test setup entry fails if credentials are invalid."""
+ mydevolo.credentials_valid.return_value = False
entry = configure_integration(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_ERROR
-@pytest.mark.parametrize("maintenance", [True])
-async def test_setup_entry_maintenance(hass: HomeAssistant) -> None:
+async def test_setup_entry_maintenance(
+ hass: HomeAssistant, mydevolo: MagicMock
+) -> None:
"""Test setup entry fails if mydevolo is in maintenance mode."""
+ mydevolo.maintenance.return_value = True
entry = configure_integration(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py
index fe62efeebac..e27331811e6 100644
--- a/tests/components/diagnostics/test_init.py
+++ b/tests/components/diagnostics/test_init.py
@@ -197,6 +197,7 @@ async def test_download_diagnostics(
"codeowners": ["test"],
"dependencies": [],
"domain": "fake_integration",
+ "integration_type": "hub",
"is_built_in": True,
"overwrites_built_in": False,
"name": "fake_integration",
@@ -301,6 +302,7 @@ async def test_download_diagnostics(
"codeowners": [],
"dependencies": [],
"domain": "fake_integration",
+ "integration_type": "hub",
"is_built_in": True,
"overwrites_built_in": False,
"name": "fake_integration",
diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py
index a0e6b7c81b8..254aad8f1da 100644
--- a/tests/components/dnsip/__init__.py
+++ b/tests/components/dnsip/__init__.py
@@ -23,6 +23,7 @@ class RetrieveDNS:
self.nameservers = nameservers
self._nameservers = ["1.2.3.4"]
self.error = error
+ self._closed = False
async def query(self, hostname, qtype) -> list[QueryResult]:
"""Return information."""
@@ -47,3 +48,7 @@ class RetrieveDNS:
@nameservers.setter
def nameservers(self, value: list[str]) -> None:
self._nameservers = value
+
+ async def close(self) -> None:
+ """Close the resolver."""
+ self._closed = True
diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py
index 66cb5cc6ad9..87e03ebceb8 100644
--- a/tests/components/dnsip/test_sensor.py
+++ b/tests/components/dnsip/test_sensor.py
@@ -171,3 +171,70 @@ async def test_sensor_no_response(
state = hass.states.get("sensor.home_assistant_io")
assert state.state == STATE_UNAVAILABLE
+
+
+async def test_sensor_timeout(
+ hass: HomeAssistant, freezer: FrozenDateTimeFactory
+) -> None:
+ """Test the DNS IP sensor with timeout."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_HOSTNAME: "home-assistant.io",
+ CONF_NAME: "home-assistant.io",
+ CONF_IPV4: True,
+ CONF_IPV6: False,
+ },
+ options={
+ CONF_RESOLVER: "208.67.222.222",
+ CONF_RESOLVER_IPV6: "2620:119:53::53",
+ CONF_PORT: 53,
+ CONF_PORT_IPV6: 53,
+ },
+ entry_id="1",
+ unique_id="home-assistant.io",
+ )
+ entry.add_to_hass(hass)
+
+ dns_mock = RetrieveDNS()
+ with patch(
+ "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
+ return_value=dns_mock,
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.home_assistant_io")
+
+ assert state.state == "1.1.1.1"
+
+ with (
+ patch(
+ "homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
+ return_value=dns_mock,
+ ),
+ patch(
+ "homeassistant.components.dnsip.sensor.asyncio.timeout",
+ side_effect=TimeoutError(),
+ ),
+ ):
+ freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ # Allows 2 retries before going unavailable
+ state = hass.states.get("sensor.home_assistant_io")
+ assert state.state == "1.1.1.1"
+ assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"]
+
+ freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.home_assistant_io")
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py
index 98b2189dfd9..493762df5ef 100644
--- a/tests/components/doorbird/test_config_flow.py
+++ b/tests/components/doorbird/test_config_flow.py
@@ -108,7 +108,9 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None:
assert result["reason"] == "link_local_address"
-async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None:
+async def test_form_zeroconf_ipv4_address(
+ hass: HomeAssistant, doorbird_api: DoorBird
+) -> None:
"""Test we abort and update the ip address from zeroconf with an ipv4 address."""
config_entry = MockConfigEntry(
@@ -118,6 +120,13 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None:
options={CONF_EVENTS: ["event1", "event2", "event3"]},
)
config_entry.add_to_hass(hass)
+
+ # Mock the API to return the correct MAC when validating
+ doorbird_api.info.return_value = {
+ "PRIMARY_MAC_ADDR": "1CCAE3AAAAAA",
+ "WIFI_MAC_ADDR": "1CCAE3BBBBBB",
+ }
+
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
@@ -136,6 +145,79 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None:
assert config_entry.data[CONF_HOST] == "4.4.4.4"
+async def test_form_zeroconf_ipv4_address_wrong_device(
+ hass: HomeAssistant, doorbird_api: DoorBird
+) -> None:
+ """Test we abort when the device MAC doesn't match during zeroconf update."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="1CCAE3AAAAAA",
+ data=VALID_CONFIG,
+ options={CONF_EVENTS: ["event1", "event2", "event3"]},
+ )
+ config_entry.add_to_hass(hass)
+
+ # Mock the API to return a different MAC (wrong device)
+ doorbird_api.info.return_value = {
+ "PRIMARY_MAC_ADDR": "1CCAE3DIFFERENT", # Different MAC!
+ "WIFI_MAC_ADDR": "1CCAE3BBBBBB",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data=ZeroconfServiceInfo(
+ ip_address=ip_address("4.4.4.4"),
+ ip_addresses=[ip_address("4.4.4.4")],
+ hostname="mock_hostname",
+ name="Doorstation - abc123._axis-video._tcp.local.",
+ port=None,
+ properties={"macaddress": "1CCAE3AAAAAA"},
+ type="mock_type",
+ ),
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "wrong_device"
+ # Host should not be updated since it's the wrong device
+ assert config_entry.data[CONF_HOST] == "1.2.3.4"
+
+
+async def test_form_zeroconf_ipv4_address_cannot_connect(
+ hass: HomeAssistant, doorbird_api: DoorBird
+) -> None:
+ """Test we abort when we cannot connect to validate during zeroconf update."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="1CCAE3AAAAAA",
+ data=VALID_CONFIG,
+ options={CONF_EVENTS: ["event1", "event2", "event3"]},
+ )
+ config_entry.add_to_hass(hass)
+
+ # Mock the API to fail connection (e.g., wrong credentials or network error)
+ doorbird_api.info.side_effect = mock_unauthorized_exception()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data=ZeroconfServiceInfo(
+ ip_address=ip_address("4.4.4.4"),
+ ip_addresses=[ip_address("4.4.4.4")],
+ hostname="mock_hostname",
+ name="Doorstation - abc123._axis-video._tcp.local.",
+ port=None,
+ properties={"macaddress": "1CCAE3AAAAAA"},
+ type="mock_type",
+ ),
+ )
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "cannot_connect"
+ # Host should not be updated since we couldn't validate
+ assert config_entry.data[CONF_HOST] == "1.2.3.4"
+
+
async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None:
"""Test we abort when we get a non ipv4 address via zeroconf."""
diff --git a/tests/components/droplet/__init__.py b/tests/components/droplet/__init__.py
new file mode 100644
index 00000000000..633b89a9749
--- /dev/null
+++ b/tests/components/droplet/__init__.py
@@ -0,0 +1,13 @@
+"""Tests for the Droplet integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+ """Fixture for setting up the component."""
+ config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
diff --git a/tests/components/droplet/conftest.py b/tests/components/droplet/conftest.py
new file mode 100644
index 00000000000..8b3792a95fe
--- /dev/null
+++ b/tests/components/droplet/conftest.py
@@ -0,0 +1,108 @@
+"""Common fixtures for the Droplet tests."""
+
+from collections.abc import Generator
+from datetime import datetime
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from homeassistant.components.droplet.const import DOMAIN
+from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT
+
+from tests.common import MockConfigEntry
+
+MOCK_CODE = "11223"
+MOCK_HOST = "192.168.1.2"
+MOCK_PORT = 443
+MOCK_DEVICE_ID = "Droplet-1234"
+MOCK_MANUFACTURER = "Hydrific, part of LIXIL"
+MOCK_SN = "1234"
+MOCK_SW_VERSION = "v1.0.0"
+MOCK_MODEL = "Droplet 1.0"
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+ """Return the default mocked config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_IP_ADDRESS: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_CODE: MOCK_CODE},
+ unique_id=MOCK_DEVICE_ID,
+ )
+
+
+@pytest.fixture
+def mock_droplet() -> Generator[AsyncMock]:
+ """Mock a Droplet client."""
+ with (
+ patch(
+ "homeassistant.components.droplet.coordinator.Droplet",
+ autospec=True,
+ ) as mock_client,
+ ):
+ client = mock_client.return_value
+ client.get_signal_quality.return_value = "strong_signal"
+ client.get_server_status.return_value = "connected"
+ client.get_flow_rate.return_value = 0.1
+ client.get_manufacturer.return_value = MOCK_MANUFACTURER
+ client.get_model.return_value = MOCK_MODEL
+ client.get_fw_version.return_value = MOCK_SW_VERSION
+ client.get_sn.return_value = MOCK_SN
+ client.get_volume_last_fetched.return_value = datetime(
+ year=2020, month=1, day=1
+ )
+ yield client
+
+
+@pytest.fixture(autouse=True)
+def mock_timeout() -> Generator[None]:
+ """Mock the timeout."""
+ with (
+ patch(
+ "homeassistant.components.droplet.coordinator.TIMEOUT",
+ 0.05,
+ ),
+ patch(
+ "homeassistant.components.droplet.coordinator.VERSION_TIMEOUT",
+ 0.1,
+ ),
+ patch(
+ "homeassistant.components.droplet.coordinator.CONNECT_DELAY",
+ 0.1,
+ ),
+ ):
+ yield
+
+
+@pytest.fixture
+def mock_droplet_connection() -> Generator[AsyncMock]:
+ """Mock a Droplet connection."""
+ with (
+ patch(
+ "homeassistant.components.droplet.config_flow.DropletConnection",
+ autospec=True,
+ ) as mock_client,
+ ):
+ client = mock_client.return_value
+ yield client
+
+
+@pytest.fixture
+def mock_droplet_discovery(request: pytest.FixtureRequest) -> Generator[AsyncMock]:
+ """Mock a DropletDiscovery."""
+ with (
+ patch(
+ "homeassistant.components.droplet.config_flow.DropletDiscovery",
+ autospec=True,
+ ) as mock_client,
+ ):
+ client = mock_client.return_value
+ # Not all tests set this value
+ try:
+ client.host = request.param
+ except AttributeError:
+ client.host = MOCK_HOST
+ client.port = MOCK_PORT
+ client.try_connect.return_value = True
+ client.get_device_id.return_value = MOCK_DEVICE_ID
+ yield client
diff --git a/tests/components/droplet/snapshots/test_sensor.ambr b/tests/components/droplet/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..aa92d6df0af
--- /dev/null
+++ b/tests/components/droplet/snapshots/test_sensor.ambr
@@ -0,0 +1,240 @@
+# serializer version: 1
+# name: test_sensors[sensor.mock_title_flow_rate-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.mock_title_flow_rate',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 2,
+ }),
+ 'sensor.private': dict({
+ 'suggested_unit_of_measurement': ,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Flow rate',
+ 'platform': 'droplet',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'current_flow_rate',
+ 'unique_id': 'Droplet-1234_current_flow_rate',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[sensor.mock_title_flow_rate-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'volume_flow_rate',
+ 'friendly_name': 'Mock Title Flow rate',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_title_flow_rate',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0264172052358148',
+ })
+# ---
+# name: test_sensors[sensor.mock_title_server_status-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'connected',
+ 'connecting',
+ 'disconnected',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.mock_title_server_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Server status',
+ 'platform': 'droplet',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'server_connectivity',
+ 'unique_id': 'Droplet-1234_server_connectivity',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[sensor.mock_title_server_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'enum',
+ 'friendly_name': 'Mock Title Server status',
+ 'options': list([
+ 'connected',
+ 'connecting',
+ 'disconnected',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_title_server_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'connected',
+ })
+# ---
+# name: test_sensors[sensor.mock_title_signal_quality-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'no_signal',
+ 'weak_signal',
+ 'strong_signal',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.mock_title_signal_quality',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Signal quality',
+ 'platform': 'droplet',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'signal_quality',
+ 'unique_id': 'Droplet-1234_signal_quality',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[sensor.mock_title_signal_quality-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'enum',
+ 'friendly_name': 'Mock Title Signal quality',
+ 'options': list([
+ 'no_signal',
+ 'weak_signal',
+ 'strong_signal',
+ ]),
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_title_signal_quality',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'strong_signal',
+ })
+# ---
+# name: test_sensors[sensor.mock_title_water-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.mock_title_water',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 2,
+ }),
+ 'sensor.private': dict({
+ 'suggested_unit_of_measurement': ,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Water',
+ 'platform': 'droplet',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'Droplet-1234_volume',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[sensor.mock_title_water-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'water',
+ 'friendly_name': 'Mock Title Water',
+ 'last_reset': '2020-01-01T00:00:00',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_title_water',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.264172052358148',
+ })
+# ---
diff --git a/tests/components/droplet/test_config_flow.py b/tests/components/droplet/test_config_flow.py
new file mode 100644
index 00000000000..88a66664c8f
--- /dev/null
+++ b/tests/components/droplet/test_config_flow.py
@@ -0,0 +1,271 @@
+"""Test Droplet config flow."""
+
+from ipaddress import IPv4Address
+from unittest.mock import AsyncMock
+
+import pytest
+
+from homeassistant.components.droplet.const import DOMAIN
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
+from homeassistant.const import (
+ ATTR_CODE,
+ CONF_CODE,
+ CONF_DEVICE_ID,
+ CONF_IP_ADDRESS,
+ CONF_PORT,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
+
+from .conftest import MOCK_CODE, MOCK_DEVICE_ID, MOCK_HOST, MOCK_PORT
+
+from tests.common import MockConfigEntry
+
+
+async def test_user_setup(
+ hass: HomeAssistant,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+) -> None:
+ """Test successful Droplet user setup."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: "192.168.1.2"},
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.CREATE_ENTRY
+ assert result.get("data") == {
+ CONF_CODE: MOCK_CODE,
+ CONF_DEVICE_ID: MOCK_DEVICE_ID,
+ CONF_IP_ADDRESS: MOCK_HOST,
+ CONF_PORT: MOCK_PORT,
+ }
+ assert result.get("context") is not None
+ assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID
+
+
+@pytest.mark.parametrize(
+ ("device_id", "connect_res"),
+ [
+ (
+ "",
+ True,
+ ),
+ (MOCK_DEVICE_ID, False),
+ ],
+ ids=["no_device_id", "cannot_connect"],
+)
+async def test_user_setup_fail(
+ hass: HomeAssistant,
+ device_id: str,
+ connect_res: bool,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+) -> None:
+ """Test user setup failing due to no device ID or failed connection."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "user"
+
+ attrs = {
+ "get_device_id.return_value": device_id,
+ "try_connect.return_value": connect_res,
+ }
+ mock_droplet_discovery.configure_mock(**attrs)
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST},
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("errors") == {"base": "cannot_connect"}
+
+ # The user should be able to try again. Maybe the droplet was disconnected from the network or something
+ attrs = {
+ "get_device_id.return_value": MOCK_DEVICE_ID,
+ "try_connect.return_value": True,
+ }
+ mock_droplet_discovery.configure_mock(**attrs)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST},
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.CREATE_ENTRY
+
+
+async def test_user_setup_already_configured(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+) -> None:
+ """Test user setup of an already-configured device."""
+ mock_config_entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST},
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "already_configured"
+
+
+async def test_zeroconf_setup(
+ hass: HomeAssistant,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+) -> None:
+ """Test successful setup of Droplet via zeroconf."""
+ discovery_info = ZeroconfServiceInfo(
+ ip_address=IPv4Address(MOCK_HOST),
+ ip_addresses=[IPv4Address(MOCK_HOST)],
+ port=MOCK_PORT,
+ hostname=MOCK_DEVICE_ID,
+ type="_droplet._tcp.local.",
+ name=MOCK_DEVICE_ID,
+ properties={},
+ )
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_CODE: MOCK_CODE}
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.CREATE_ENTRY
+ assert result.get("data") == {
+ CONF_DEVICE_ID: MOCK_DEVICE_ID,
+ CONF_IP_ADDRESS: MOCK_HOST,
+ CONF_PORT: MOCK_PORT,
+ CONF_CODE: MOCK_CODE,
+ }
+ assert result.get("context") is not None
+ assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID
+
+
+@pytest.mark.parametrize("mock_droplet_discovery", ["192.168.1.5"], indirect=True)
+async def test_zeroconf_update(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+) -> None:
+ """Test updating Droplet's host with zeroconf."""
+ mock_config_entry.add_to_hass(hass)
+
+ # We start with a different host
+ new_host = "192.168.1.5"
+ assert mock_config_entry.data[CONF_IP_ADDRESS] != new_host
+
+ # After this discovery message, host should be updated
+ discovery_info = ZeroconfServiceInfo(
+ ip_address=IPv4Address(new_host),
+ ip_addresses=[IPv4Address(new_host)],
+ port=MOCK_PORT,
+ hostname=MOCK_DEVICE_ID,
+ type="_droplet._tcp.local.",
+ name=MOCK_DEVICE_ID,
+ properties={},
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "already_configured"
+
+ assert mock_config_entry.data[CONF_IP_ADDRESS] == new_host
+
+
+async def test_zeroconf_invalid_discovery(hass: HomeAssistant) -> None:
+ """Test that invalid discovery information causes the config flow to abort."""
+ discovery_info = ZeroconfServiceInfo(
+ ip_address=IPv4Address(MOCK_HOST),
+ ip_addresses=[IPv4Address(MOCK_HOST)],
+ port=-1,
+ hostname=MOCK_DEVICE_ID,
+ type="_droplet._tcp.local.",
+ name=MOCK_DEVICE_ID,
+ properties={},
+ )
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
+ )
+ assert result is not None
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "invalid_discovery_info"
+
+
+async def test_confirm_cannot_connect(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet_discovery: AsyncMock,
+) -> None:
+ """Test that config flow fails when Droplet can't connect."""
+ discovery_info = ZeroconfServiceInfo(
+ ip_address=IPv4Address(MOCK_HOST),
+ ip_addresses=[IPv4Address(MOCK_HOST)],
+ port=MOCK_PORT,
+ hostname=MOCK_DEVICE_ID,
+ type="_droplet._tcp.local.",
+ name=MOCK_DEVICE_ID,
+ properties={},
+ )
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
+ )
+ assert result.get("type") is FlowResultType.FORM
+
+ # Mock the connection failing
+ mock_droplet_discovery.try_connect.return_value = False
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {ATTR_CODE: MOCK_CODE}
+ )
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("errors")["base"] == "cannot_connect"
+
+ mock_droplet_discovery.try_connect.return_value = True
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={ATTR_CODE: MOCK_CODE}
+ )
+ assert result.get("type") is FlowResultType.CREATE_ENTRY, result
diff --git a/tests/components/droplet/test_init.py b/tests/components/droplet/test_init.py
new file mode 100644
index 00000000000..7c4f98c62e7
--- /dev/null
+++ b/tests/components/droplet/test_init.py
@@ -0,0 +1,41 @@
+"""Test Droplet initialization."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry
+
+
+async def test_setup_no_version_info(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test coordinator setup where Droplet never sends version info."""
+ mock_droplet.version_info_available.return_value = False
+ await setup_integration(hass, mock_config_entry)
+
+ assert "Failed to get version info from Droplet" in caplog.text
+
+
+async def test_setup_droplet_offline(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+) -> None:
+ """Test integration setup when Droplet is offline."""
+ mock_droplet.connected = False
+ await setup_integration(hass, mock_config_entry)
+
+ assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/droplet/test_sensor.py b/tests/components/droplet/test_sensor.py
new file mode 100644
index 00000000000..9dcc72403f6
--- /dev/null
+++ b/tests/components/droplet/test_sensor.py
@@ -0,0 +1,46 @@
+"""Test Droplet sensors."""
+
+from unittest.mock import AsyncMock
+
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+async def test_sensors(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test Droplet sensors."""
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_sensors_update_data(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_droplet_discovery: AsyncMock,
+ mock_droplet_connection: AsyncMock,
+ mock_droplet: AsyncMock,
+) -> None:
+ """Test Droplet async update data."""
+ await setup_integration(hass, mock_config_entry)
+
+ assert hass.states.get("sensor.mock_title_flow_rate").state == "0.0264172052358148"
+
+ mock_droplet.get_flow_rate.return_value = 0.5
+
+ mock_droplet.listen_forever.call_args_list[0][0][1]({})
+
+ assert hass.states.get("sensor.mock_title_flow_rate").state == "0.132086026179074"
diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py
index 961c9831f44..c6031781c66 100644
--- a/tests/components/dsmr/test_config_flow.py
+++ b/tests/components/dsmr/test_config_flow.py
@@ -85,7 +85,7 @@ async def test_setup_network_rfxtrx(
],
) -> None:
"""Test we can setup network."""
- (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
+ (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -245,7 +245,7 @@ async def test_setup_serial_rfxtrx(
],
) -> None:
"""Test we can setup serial."""
- (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
+ (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture
port = com_port()
@@ -344,7 +344,7 @@ async def test_setup_serial_fail(
dsmr_connection_send_validate_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test failed serial connection."""
- (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
+ (_connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
port = com_port()
@@ -395,10 +395,10 @@ async def test_setup_serial_timeout(
],
) -> None:
"""Test failed serial connection."""
- (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
+ (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture
(
- connection_factory,
- transport,
+ _connection_factory,
+ _transport,
rfxtrx_protocol,
) = rfxtrx_dsmr_connection_send_validate_fixture
@@ -453,10 +453,10 @@ async def test_setup_serial_wrong_telegram(
],
) -> None:
"""Test failed telegram data."""
- (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture
+ (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture
(
- rfxtrx_connection_factory,
- transport,
+ _rfxtrx_connection_factory,
+ _transport,
rfxtrx_protocol,
) = rfxtrx_dsmr_connection_send_validate_fixture
diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py
index 9bcde251f6f..f2a475097ae 100644
--- a/tests/components/dsmr/test_diagnostics.py
+++ b/tests/components/dsmr/test_diagnostics.py
@@ -26,7 +26,7 @@ async def test_diagnostics(
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py
index d590666b060..8ad41147135 100644
--- a/tests/components/dsmr/test_mbus_migration.py
+++ b/tests/components/dsmr/test_mbus_migration.py
@@ -27,7 +27,7 @@ async def test_migrate_gas_to_mbus(
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test migration of unique_id."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
mock_entry = MockConfigEntry(
domain=DOMAIN,
@@ -138,7 +138,7 @@ async def test_migrate_hourly_gas_to_mbus(
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test migration of unique_id."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
mock_entry = MockConfigEntry(
domain=DOMAIN,
@@ -249,7 +249,7 @@ async def test_migrate_gas_with_devid_to_mbus(
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test migration of unique_id."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
mock_entry = MockConfigEntry(
domain=DOMAIN,
@@ -357,7 +357,7 @@ async def test_migrate_gas_to_mbus_exists(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test migration of unique_id."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
mock_entry = MockConfigEntry(
domain=DOMAIN,
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
index 5657c5999ce..7e01431a5dc 100644
--- a/tests/components/dsmr/test_sensor.py
+++ b/tests/components/dsmr/test_sensor.py
@@ -57,7 +57,7 @@ async def test_default_setup(
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test the default setup."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -205,7 +205,7 @@ async def test_setup_only_energy(
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test the default setup."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -260,7 +260,7 @@ async def test_v4_meter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if v4 meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -348,7 +348,7 @@ async def test_v5_meter(
state: str,
) -> None:
"""Test if v5 meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -421,7 +421,7 @@ async def test_luxembourg_meter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if v5 meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -516,7 +516,7 @@ async def test_eonhu_meter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if v5 meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -586,7 +586,7 @@ async def test_belgian_meter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if Belgian meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -820,7 +820,7 @@ async def test_belgian_meter_alt(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if Belgian meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1008,7 +1008,7 @@ async def test_belgian_meter_mbus(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if Belgian meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1158,7 +1158,7 @@ async def test_belgian_meter_low(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if Belgian meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1207,7 +1207,7 @@ async def test_swedish_meter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if v5 meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1282,7 +1282,7 @@ async def test_easymeter(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if Q3D meter is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1360,7 +1360,7 @@ async def test_tcp(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""If proper config provided TCP connection should be made."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"host": "localhost",
@@ -1389,7 +1389,7 @@ async def test_rfxtrx_tcp(
rfxtrx_dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""If proper config provided RFXtrx TCP connection should be made."""
- (connection_factory, transport, protocol) = rfxtrx_dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = rfxtrx_dsmr_connection_fixture
entry_data = {
"host": "localhost",
@@ -1418,7 +1418,7 @@ async def test_connection_errors_retry(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Connection should be retried on error during setup."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (_connection_factory, transport, protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1457,7 +1457,7 @@ async def test_reconnect(
) -> None:
"""If transport disconnects, the connection should be retried."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1540,7 +1540,7 @@ async def test_gas_meter_providing_energy_reading(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test that gas providing energy readings use the correct device class."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
@@ -1595,7 +1595,7 @@ async def test_heat_meter_mbus(
hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock]
) -> None:
"""Test if heat meter reading is correctly parsed."""
- (connection_factory, transport, protocol) = dsmr_connection_fixture
+ (connection_factory, _transport, _protocol) = dsmr_connection_fixture
entry_data = {
"port": "/dev/ttyUSB0",
diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py
index 22039d6c0bc..b33ed28c944 100644
--- a/tests/components/ecovacs/conftest.py
+++ b/tests/components/ecovacs/conftest.py
@@ -1,6 +1,7 @@
"""Common fixtures for the Ecovacs tests."""
from collections.abc import AsyncGenerator, Generator
+import logging
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@@ -134,6 +135,7 @@ def mock_vacbot(device_fixture: str) -> Generator[Mock]:
vacbot.lifespanEvents = EventEmitter()
vacbot.errorEvents = EventEmitter()
vacbot.battery_status = None
+ vacbot.charge_status = None
vacbot.fan_speed = None
vacbot.components = {}
yield vacbot
@@ -159,6 +161,7 @@ def platforms() -> Platform | list[Platform]:
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
mock_config_entry: MockConfigEntry,
mock_authenticator: Mock,
mock_mqtt_client: Mock,
@@ -177,6 +180,12 @@ async def init_integration(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
+
+ # No errors should be logged during setup
+ assert not [t for t in caplog.record_tuples if t[1] >= logging.ERROR], (
+ "Errors during integration setup"
+ )
+
yield mock_config_entry
diff --git a/tests/components/ecovacs/fixtures/devices/n0vyif/device.json b/tests/components/ecovacs/fixtures/devices/n0vyif/device.json
new file mode 100644
index 00000000000..71aec03a786
--- /dev/null
+++ b/tests/components/ecovacs/fixtures/devices/n0vyif/device.json
@@ -0,0 +1,27 @@
+{
+ "did": "E1234567890000000009",
+ "name": "E1234567890000000009",
+ "class": "n0vyif",
+ "resource": "eSQtNR9N",
+ "company": "eco-ng",
+ "service": {
+ "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net",
+ "mqs": "api-ngiot.dc-eu.ww.ecouser.net"
+ },
+ "deviceName": "DEEBOT X8 PRO OMNI",
+ "icon": "https://api-app.dc-eu.ww.ecouser.net/api/pim/file/get/66e3ac63a2928902a25d83a0",
+ "ota": true,
+ "UILogicId": "keplerh_ww_h_keplerh5",
+ "materialNo": "110-2417-0402",
+ "pid": "66daaa789dd37cf146cb1d2e",
+ "product_category": "DEEBOT",
+ "model": "KEPLER_BLACK_AI_INT",
+ "updateInfo": {
+ "needUpdate": false,
+ "changeLog": ""
+ },
+ "nick": "X8 PRO OMNI",
+ "homeSort": 9999,
+ "status": 1,
+ "otaUpgrade": {}
+}
diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr
index b89a490c772..f35ee92ceb8 100644
--- a/tests/components/ecovacs/snapshots/test_number.ambr
+++ b/tests/components/ecovacs/snapshots/test_number.ambr
@@ -114,6 +114,177 @@
'state': '3',
})
# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_clean_count:entity-registry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max': 4,
+ 'min': 1,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'number',
+ 'entity_category': ,
+ 'entity_id': 'number.x8_pro_omni_clean_count',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Clean count',
+ 'platform': 'ecovacs',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'clean_count',
+ 'unique_id': 'E1234567890000000009_clean_count',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_clean_count:state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'X8 PRO OMNI Clean count',
+ 'max': 4,
+ 'min': 1,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'context': ,
+ 'entity_id': 'number.x8_pro_omni_clean_count',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1',
+ })
+# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_volume:entity-registry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max': 11,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'number',
+ 'entity_category': ,
+ 'entity_id': 'number.x8_pro_omni_volume',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Volume',
+ 'platform': 'ecovacs',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'volume',
+ 'unique_id': 'E1234567890000000009_volume',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_volume:state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'X8 PRO OMNI Volume',
+ 'max': 11,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'context': ,
+ 'entity_id': 'number.x8_pro_omni_volume',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '5',
+ })
+# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_water_flow_level:entity-registry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max': 50,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'number',
+ 'entity_category': ,
+ 'entity_id': 'number.x8_pro_omni_water_flow_level',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Water flow level',
+ 'platform': 'ecovacs',
+ 'previous_unique_id': None,
+ 'suggested_object_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'water_amount',
+ 'unique_id': 'E1234567890000000009_water_amount',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_number_entities[n0vyif][number.x8_pro_omni_water_flow_level:state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'X8 PRO OMNI Water flow level',
+ 'max': 50,
+ 'min': 0,
+ 'mode': ,
+ 'step': 1.0,
+ }),
+ 'context': ,
+ 'entity_id': 'number.x8_pro_omni_water_flow_level',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '14',
+ })
+# ---
# name: test_number_entities[yna5x1][number.ozmo_950_volume:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr
index 420a4a2d48e..f8e269593d9 100644
--- a/tests/components/ecovacs/snapshots/test_select.ambr
+++ b/tests/components/ecovacs/snapshots/test_select.ambr
@@ -1,4 +1,65 @@
# serializer version: 1
+# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:entity-registry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'mop',
+ 'mop_after_vacuum',
+ 'vacuum',
+ 'vacuum_and_mop',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'select',
+ 'entity_category': ,
+ 'entity_id': 'select.x8_pro_omni_work_mode',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id':